diff --git a/README.md b/README.md index 7d5b38f42..8cc76885f 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ -[![Java Build with Maven](https://github.com/cap-java/cds-feature-attachments/actions/workflows/main.yml/badge.svg)](https://github.com/cap-java/cds-feature-attachments/actions/workflows/main.yml) -[![Deploy new Version with Maven](https://github.com/cap-java/cds-feature-attachments/actions/workflows/release.yml/badge.svg)](https://github.com/cap-java/cds-feature-attachments/actions/workflows/release.yml) +[![Java Build with Maven](https://github.com/cap-java/cds-feature-attachments/actions/workflows/main.yml/badge.svg)](https://github.com/cap-java/cds-feature-attachments/actions/workflows/main.yml) +[![Deploy new Version with Maven](https://github.com/cap-java/cds-feature-attachments/actions/workflows/release.yml/badge.svg)](https://github.com/cap-java/cds-feature-attachments/actions/workflows/release.yml) [![REUSE status](https://api.reuse.software/badge/github.com/cap-java/cds-feature-attachments)](https://api.reuse.software/info/github.com/cap-java/cds-feature-attachments) # Attachments Plugin for SAP Cloud Application Programming Model (CAP) @@ -14,30 +14,31 @@ It supports the [AWS, Azure, and Google object stores](storage-targets/cds-featu -* [Quick Start](#quick-start) -* [Usage](#usage) - * [MVN Setup](#mvn-setup) - * [Changes in the CDS Models and for the UI](#changes-in-the-cds-models-and-for-the-UI) - * [Try the Bookshop Sample](#try-the-bookshop-sample) - * [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) - * [HTTP Endpoint](#http-endpoint) - * [Security](#security) -* [Releases: Maven Central and Artifactory](#releases-maven-central-and-artifactory) -* [Minimum UI5 and CAP Java Version](#minimum-ui5-and-cap-java-version) -* [Architecture Overview](#architecture-overview) - * [Design](#design) - * [Multitenancy](#multitenancy) - * [Object Stores](#object-stores) - * [Model Texts](#model-texts) -* [Monitoring \& Logging](#monitoring--logging) -* [Support, Feedback, Contributing](#support-feedback-contributing) -* [References \& Links](#references--links) +- [Quick Start](#quick-start) +- [Usage](#usage) + - [MVN Setup](#mvn-setup) + - [Changes in the CDS Models and for the UI](#changes-in-the-cds-models-and-for-the-UI) + - [Single (Inline) Attachments](#single-inline-attachments) + - [Try the Bookshop Sample](#try-the-bookshop-sample) + - [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) + - [HTTP Endpoint](#http-endpoint) + - [Security](#security) +- [Releases: Maven Central and Artifactory](#releases-maven-central-and-artifactory) +- [Minimum UI5 and CAP Java Version](#minimum-ui5-and-cap-java-version) +- [Architecture Overview](#architecture-overview) + - [Design](#design) + - [Multitenancy](#multitenancy) + - [Object Stores](#object-stores) + - [Model Texts](#model-texts) +- [Monitoring \& Logging](#monitoring--logging) +- [Support, Feedback, Contributing](#support-feedback-contributing) +- [References \& Links](#references--links) ## Quick Start @@ -95,7 +96,7 @@ To use this file with the [incidents app](https://github.com/cap-java/incidents- ```cds using { sap.capire.incidents as my } from '../db/schema'; -using { sap.attachments.Attachments } from 'com.sap.cds/cds-feature-attachments'; +using { Attachments } from 'com.sap.cds/cds-feature-attachments'; extend my.Incidents with { attachments: Composition of many Attachments; } @@ -115,19 +116,56 @@ annotate service.Incidents with @( The UI Facet can also be added directly after other UI Facets in a `cds` file in the `app` folder. -### Try the Bookshop Sample +### Single (Inline) Attachments -The easiest way to get started is with the included [bookshop sample](samples/bookshop/): +> [!Important] +> Inline attachments require **cds-services 4.9.0** or higher and are available from **cds-feature-attachments 1.6.0**. -```bash -cd samples/bookshop -mvn compile -mvn spring-boot:run +In addition to the composition-based `Attachments` aspect (which supports multiple files), `cds-feature-attachments` provides the `Attachment` type for **single-file** attachment fields directly on an entity. This is useful when an entity needs exactly one file, for example a profile icon or a cover image. + +```cds +using { Attachment } from 'com.sap.cds/cds-feature-attachments'; + +entity Books { + key ID : UUID; + title : String; + profileIcon : Attachment; + coverImage : Attachment; +} ``` -Then browse to http://localhost:8080/browse/index.html to see attachments in action. +CDS flattens inline attachment fields onto the parent entity. For example, `profileIcon : Attachment` generates the following columns on the `Books` table: + +- `profileIcon_content` (LargeBinary) +- `profileIcon_mimeType` (String) +- `profileIcon_fileName` (String) +- `profileIcon_contentId` (String) +- `profileIcon_status` (StatusCode) +- `profileIcon_scannedAt` (Timestamp) +- `profileIcon_note` (String) + +All plugin features: malware scanning, storage targets, maximum file size, and MIME type validation work the same way for inline attachments as for composition-based attachments. + +#### UI Annotations for Inline Attachments -For detailed setup instructions and implementation details, see the [bookshop sample README](samples/bookshop/README.md). +To display inline attachments in a Fiori Elements UI, use a `FieldGroup` referencing the flattened field names: + +```cds +annotate AdminService.Books with @(UI: { + Facets: [ + // ... other facets ... + { + $Type : 'UI.ReferenceFacet', + Label : 'Profile Icon', + Target: '@UI.FieldGroup#ProfileIcon' + } + ], + FieldGroup #ProfileIcon: {Data: [ + {Value: profileIcon_content}, + {Value: profileIcon_status} + ]} +}); +``` ### Storage Targets @@ -144,8 +182,7 @@ When using a dedicated storage target, the attachment is not stored in the under ### Malware Scanner -This plugin checks for a binding to -the [SAP Malware Scanning Service](https://help.sap.com/docs/malware-scanning-servce), which needs to have the label `malware-scanner`. The entry in the [mta-file](https://cap.cloud.sap/docs/guides/deployment/to-cf#add-mta-yaml) may look like: +This plugin checks for a binding to the [SAP Malware Scanning Service](https://help.sap.com/docs/malware-scanning-servce), which needs to have the label `malware-scanner`. The entry in the [mta-file](https://cap.cloud.sap/docs/guides/deployment/to-cf#add-mta-yaml) may look like: ``` _schema-version: '0.1' @@ -212,6 +249,7 @@ annotate Books.attachments with { ``` The @Validation.Maximum value is a size string consisting of a number followed by a unit. The following units are supported: + - B (bytes) - KB, MB, GB, TB (decimal units) - KiB, MiB, GiB, TiB (binary units) @@ -249,7 +287,6 @@ annotate Books.attachments with { } ``` - ### Outbox In this plugin the [persistent outbox](https://cap.cloud.sap/docs/java/outbox#persistent) is used to mark attachments as @@ -343,7 +380,7 @@ In the Spring Boot context the `AttachmentService` can be autowired in the handl To secure the endpoint, security annotations can be used. For example: ```cds -using {sap.attachments.Attachments} from `com.sap.cds/cds-feature-attachments`; +using {Attachments} from `com.sap.cds/cds-feature-attachments`; entity Items : cuid { ... 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 92c477234..00fc15ac1 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 @@ -18,6 +18,7 @@ import com.sap.cds.feature.attachments.handler.applicationservice.transaction.CreationChangeSetListener; import com.sap.cds.feature.attachments.handler.applicationservice.transaction.ListenerProvider; import com.sap.cds.feature.attachments.handler.common.AssociationCascader; +import com.sap.cds.feature.attachments.handler.common.AttachmentContext; import com.sap.cds.feature.attachments.handler.common.AttachmentsReader; import com.sap.cds.feature.attachments.handler.draftservice.DraftActiveAttachmentsHandler; import com.sap.cds.feature.attachments.handler.draftservice.DraftCancelAttachmentsHandler; @@ -132,9 +133,9 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) { new DefaultAttachmentMalwareScanner(persistenceService, attachmentService, scanClient); EndTransactionMalwareScanProvider malwareScanEndTransactionListener = - (attachmentEntity, contentId) -> + (attachmentEntity, contentId, attachmentContext) -> new EndTransactionMalwareScanRunner( - attachmentEntity, contentId, malwareScanner, runtime); + attachmentEntity, contentId, attachmentContext, malwareScanner, runtime); // register event handlers for attachment service configurer.eventHandler( @@ -163,7 +164,8 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) { eventFactory, attachmentsReader, outboxedAttachmentService, storage, defaultMaxSize)); configurer.eventHandler(new DeleteAttachmentsHandler(attachmentsReader, deleteEvent)); EndTransactionMalwareScanRunner scanRunner = - new EndTransactionMalwareScanRunner(null, null, malwareScanner, runtime); + new EndTransactionMalwareScanRunner( + null, null, new AttachmentContext.Composition(), malwareScanner, runtime); configurer.eventHandler( new ReadAttachmentsHandler( attachmentService, diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/DeleteAttachmentsHandler.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/DeleteAttachmentsHandler.java index a11f3e6da..0577041ba 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/DeleteAttachmentsHandler.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/DeleteAttachmentsHandler.java @@ -10,6 +10,7 @@ import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments; import com.sap.cds.feature.attachments.handler.applicationservice.modifyevents.MarkAsDeletedAttachmentEvent; import com.sap.cds.feature.attachments.handler.common.ApplicationHandlerHelper; +import com.sap.cds.feature.attachments.handler.common.AttachmentContext; import com.sap.cds.feature.attachments.handler.common.AttachmentsReader; import com.sap.cds.services.cds.ApplicationService; import com.sap.cds.services.cds.CdsDeleteEventContext; @@ -52,9 +53,12 @@ void processBefore(CdsDeleteEventContext context) { context.getModel(), context.getTarget(), context.getCqn()); Converter converter = - (path, element, value) -> - deleteEvent.processEvent( - path, (InputStream) value, Attachments.of(path.target().values()), context); + (path, element, value) -> { + AttachmentContext attachmentCtx = AttachmentContext.from(path.target().type(), element); + Attachments attachment = attachmentCtx.extractFrom(path.target().values()); + return deleteEvent.processEvent( + path, (InputStream) value, attachment, context, attachmentCtx); + }; CdsDataProcessor.create() .addConverter(ApplicationHandlerHelper.MEDIA_CONTENT_FILTER, converter) diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/ReadAttachmentsHandler.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/ReadAttachmentsHandler.java index 253e57d20..95034bd43 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/ReadAttachmentsHandler.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/ReadAttachmentsHandler.java @@ -16,6 +16,7 @@ import com.sap.cds.feature.attachments.handler.applicationservice.readhelper.LazyProxyInputStream; import com.sap.cds.feature.attachments.handler.common.ApplicationHandlerHelper; import com.sap.cds.feature.attachments.handler.common.AssociationCascader; +import com.sap.cds.feature.attachments.handler.common.AttachmentContext; import com.sap.cds.feature.attachments.service.AttachmentService; import com.sap.cds.feature.attachments.service.malware.AsyncMalwareScanExecutor; import com.sap.cds.ql.CQL; @@ -99,8 +100,11 @@ void processBefore(CdsReadEventContext context) { CdsModel cdsModel = context.getModel(); List fieldNames = cascader.findMediaAssociationNames(cdsModel, context.getTarget()); - if (!fieldNames.isEmpty()) { - CqnSelect resultCqn = CQL.copy(context.getCqn(), new BeforeReadItemsModifier(fieldNames)); + List inlinePrefixes = + ApplicationHandlerHelper.getInlineAttachmentFieldNames(context.getTarget()); + if (!fieldNames.isEmpty() || !inlinePrefixes.isEmpty()) { + CqnSelect resultCqn = + CQL.copy(context.getCqn(), new BeforeReadItemsModifier(fieldNames, inlinePrefixes)); context.setCqn(resultCqn); } } @@ -114,10 +118,11 @@ void processAfter(CdsReadEventContext context, List data) { Converter converter = (path, element, value) -> { - Attachments attachment = Attachments.of(path.target().values()); + AttachmentContext attachmentCtx = AttachmentContext.from(path.target().type(), element); + Attachments attachment = attachmentCtx.extractFrom(path.target().values()); InputStream content = attachment.getContent(); if (nonNull(attachment.getContentId())) { - verifyStatus(path, attachment); + verifyStatus(path, attachment, attachmentCtx); Supplier supplier = nonNull(content) ? () -> content @@ -134,8 +139,8 @@ void processAfter(CdsReadEventContext context, List data) { } } - private void verifyStatus(Path path, Attachments attachment) { - if (areKeysEmpty(path.target().keys())) { + private void verifyStatus(Path path, Attachments attachment, AttachmentContext attachmentCtx) { + if (areKeysEmpty(path.target().keys()) || attachmentCtx.isInline()) { String currentStatus = attachment.getStatus(); logger.debug( "In verify status for content id {} and status {}", @@ -143,13 +148,13 @@ private void verifyStatus(Path path, Attachments attachment) { currentStatus); if (scannerAvailable && needsScan(currentStatus, attachment.getScannedAt())) { if (StatusCode.CLEAN.equals(currentStatus)) { - transitionToScanning(path.target().entity(), attachment); + transitionToScanning(path.target().entity(), attachment, attachmentCtx); } logger.debug( "Scanning content with ID {} for malware, has current status {}", attachment.getContentId(), currentStatus); - scanExecutor.scanAsync(path.target().entity(), attachment.getContentId()); + scanExecutor.scanAsync(path.target().entity(), attachment.getContentId(), attachmentCtx); } statusValidator.verifyStatus(attachment.getStatus()); } @@ -168,21 +173,25 @@ private boolean isScanStale(Instant scannedAt) { return scannedAt == null || Instant.now().isAfter(scannedAt.plus(RESCAN_THRESHOLD)); } - private void transitionToScanning(CdsEntity entity, Attachments attachment) { + private void transitionToScanning( + CdsEntity entity, Attachments attachment, AttachmentContext attachmentCtx) { logger.debug( "Attachment {} has stale scan (scannedAt={}), transitioning to SCANNING for rescan.", attachment.getContentId(), attachment.getScannedAt()); + String contentIdCol = attachmentCtx.fieldName(Attachments.CONTENT_ID); + String statusCol = attachmentCtx.fieldName(Attachments.STATUS); + Attachments updateData = Attachments.create(); - updateData.setStatus(StatusCode.SCANNING); + updateData.put(statusCol, StatusCode.SCANNING); // Filter by contentId because primary keys are unavailable during content-only reads // (areKeysEmpty returns true). This is consistent with DefaultAttachmentMalwareScanner. CqnUpdate update = Update.entity(entity) .data(updateData) - .where(entry -> entry.get(Attachments.CONTENT_ID).eq(attachment.getContentId())); + .where(entry -> entry.get(contentIdCol).eq(attachment.getContentId())); persistenceService.run(update); attachment.setStatus(StatusCode.SCANNING); 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 0747b36c2..c5179962d 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 @@ -98,10 +98,25 @@ void processBefore(CdsUpdateEventContext context, List data) { } private boolean associationsAreUnchanged(CdsEntity entity, List data) { - return entity - .compositions() - .noneMatch( - association -> data.stream().anyMatch(d -> d.containsKey(association.getName()))); + // Check composition associations + boolean compositionsUnchanged = + entity + .compositions() + .noneMatch( + association -> data.stream().anyMatch(d -> d.containsKey(association.getName()))); + + // Also check inline attachment fields + List inlinePrefixes = ApplicationHandlerHelper.getInlineAttachmentFieldNames(entity); + boolean inlineUnchanged = + inlinePrefixes.stream() + .noneMatch( + prefix -> + data.stream() + .anyMatch( + d -> + d.keySet().stream().anyMatch(key -> key.startsWith(prefix + "_")))); + + return compositionsUnchanged && inlineUnchanged; } private void deleteRemovedAttachments( 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 2c315bbe9..2029bfbaa 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 @@ -11,8 +11,10 @@ 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.common.AttachmentContext; import com.sap.cds.ql.cqn.Path; import com.sap.cds.reflect.CdsAnnotation; +import com.sap.cds.reflect.CdsElement; import com.sap.cds.reflect.CdsEntity; import com.sap.cds.services.ErrorStatuses; import com.sap.cds.services.EventContext; @@ -20,6 +22,7 @@ import java.io.InputStream; import java.util.List; import java.util.Map; +import java.util.Optional; public final class ModifyApplicationHandlerHelper { @@ -51,14 +54,17 @@ public static void handleAttachmentForEntities( ApplicationHandlerHelper.condenseAttachments(existingAttachments, entity); Converter converter = - (path, element, value) -> - handleAttachmentForEntity( - condensedExistingAttachments, - eventFactory, - eventContext, - path, - (InputStream) value, - defaultMaxSize); + (path, element, value) -> { + AttachmentContext context = AttachmentContext.from(path.target().type(), element); + return handleAttachmentForEntity( + condensedExistingAttachments, + eventFactory, + eventContext, + path, + (InputStream) value, + defaultMaxSize, + context); + }; CdsDataProcessor.create() .addConverter(ApplicationHandlerHelper.MEDIA_CONTENT_FILTER, converter) @@ -74,6 +80,7 @@ public static void handleAttachmentForEntities( * @param path the {@link Path} of the attachment * @param content the content of the attachment * @param defaultMaxSize the default max size to use when no annotation is present + * @param context the attachment context describing how to address this attachment's fields * @return the processed content as an {@link InputStream} */ public static InputStream handleAttachmentForEntity( @@ -82,13 +89,17 @@ public static InputStream handleAttachmentForEntity( EventContext eventContext, Path path, InputStream content, - String defaultMaxSize) { + String defaultMaxSize, + AttachmentContext context) { Map keys = ApplicationHandlerHelper.removeDraftKey(path.target().keys()); - ReadonlyDataContextEnhancer.restoreReadonlyFields((CdsData) path.target().values()); - Attachments attachment = getExistingAttachment(keys, existingAttachments); - String contentId = (String) path.target().values().get(Attachments.CONTENT_ID); + ReadonlyDataContextEnhancer.restoreReadonlyFields((CdsData) path.target().values(), context); + Attachments attachment = getExistingAttachment(keys, existingAttachments, context); + + String contentId = + (String) path.target().values().get(context.fieldName(Attachments.CONTENT_ID)); + String contentLength = eventContext.getParameterInfo().getHeader("Content-Length"); - String maxSizeStr = getValMaxValue(path.target().entity(), defaultMaxSize); + String maxSizeStr = getValMaxValue(path.target().entity(), defaultMaxSize, context); eventContext.put( "attachment.MaxSize", maxSizeStr); // make max size available in context for error handling later @@ -112,7 +123,7 @@ public static InputStream handleAttachmentForEntity( ModifyAttachmentEvent eventToProcess = eventFactory.getEvent(wrappedContent, contentId, attachment); try { - return eventToProcess.processEvent(path, wrappedContent, attachment, eventContext); + return eventToProcess.processEvent(path, wrappedContent, attachment, eventContext, context); } catch (Exception e) { if (wrappedContent != null && wrappedContent.isLimitExceeded()) { throw tooLargeException; @@ -121,9 +132,10 @@ public static InputStream handleAttachmentForEntity( } } - private static String getValMaxValue(CdsEntity entity, String defaultMaxSize) { - return entity - .findElement("content") + private static String getValMaxValue( + CdsEntity entity, String defaultMaxSize, AttachmentContext context) { + Optional contentElement = entity.findElement(context.fieldName("content")); + return contentElement .flatMap(e -> e.findAnnotation("Validation.Maximum")) .map(CdsAnnotation::getValue) .filter(v -> !"true".equals(v.toString())) @@ -132,9 +144,9 @@ private static String getValMaxValue(CdsEntity entity, String defaultMaxSize) { } private static Attachments getExistingAttachment( - Map keys, List existingAttachments) { + Map keys, List existingAttachments, AttachmentContext context) { return existingAttachments.stream() - .filter(existingData -> ApplicationHandlerHelper.areKeysInData(keys, existingData)) + .filter(existingData -> context.matches(existingData, keys)) .findAny() .orElse(Attachments.create()); } diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ReadonlyDataContextEnhancer.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ReadonlyDataContextEnhancer.java index 2df30e3d1..6212c7ea6 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ReadonlyDataContextEnhancer.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ReadonlyDataContextEnhancer.java @@ -7,7 +7,9 @@ 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.generated.cds4j.sap.attachments.MediaData; import com.sap.cds.feature.attachments.handler.common.ApplicationHandlerHelper; +import com.sap.cds.feature.attachments.handler.common.AttachmentContext; import com.sap.cds.reflect.CdsEntity; import java.util.List; import java.util.Objects; @@ -21,9 +23,9 @@ public final class ReadonlyDataContextEnhancer { private static final String DRAFT_READONLY_CONTEXT = "DRAFT_READONLY_CONTEXT"; /** - * Preserves the readonly fields of an {@link Attachments attachment} in a custom field with the - * name {@value #DRAFT_READONLY_CONTEXT}. These readonly data will be removed from the data by the - * CAP Java runtime, but the preserved copy still exists. + * Preserves the readonly fields of an {@link Attachments attachment} in a custom field. These + * readonly data will be removed from the data by the CAP Java runtime, but the preserved copy + * still exists. * * @param target the target {@link CdsEntity entity} * @param data the list of {@link CdsData data} to enhance @@ -34,15 +36,21 @@ public static void preserveReadonlyFields(CdsEntity target, List data, Validator validator = (path, element, value) -> { + AttachmentContext context = AttachmentContext.from(path.target().type(), element); if (isDraft) { - Attachments values = Attachments.of(path.target().values()); Attachments attachment = Attachments.create(); - attachment.setContentId(values.getContentId()); - attachment.setStatus(values.getStatus()); - attachment.setScannedAt(values.getScannedAt()); - path.target().values().put(DRAFT_READONLY_CONTEXT, attachment); + attachment.setContentId( + (String) path.target().values().get(context.fieldName(Attachments.CONTENT_ID))); + attachment.setStatus( + (String) path.target().values().get(context.fieldName(Attachments.STATUS))); + attachment.setScannedAt( + (java.time.Instant) + path.target().values().get(context.fieldName(Attachments.SCANNED_AT))); + attachment.setFileName( + (String) path.target().values().get(context.fieldName(MediaData.FILE_NAME))); + path.target().values().put(context.fieldName(DRAFT_READONLY_CONTEXT), attachment); } else { - path.target().values().remove(DRAFT_READONLY_CONTEXT); + path.target().values().remove(context.fieldName(DRAFT_READONLY_CONTEXT)); } }; @@ -52,18 +60,23 @@ public static void preserveReadonlyFields(CdsEntity target, List data, } /** - * Restores the readonly fields with the backup from the data in the custom field {@value - * #DRAFT_READONLY_CONTEXT}. + * Restores the readonly fields for the attachment described by the given context from the + * preserved backup in the data map. * * @param data the {@link CdsData data} to restore with readonly fields + * @param context the attachment context identifying which attachment's fields to restore */ - public static void restoreReadonlyFields(CdsData data) { - CdsData readOnlyData = (CdsData) data.get(DRAFT_READONLY_CONTEXT); + public static void restoreReadonlyFields(CdsData data, AttachmentContext context) { + String readonlyKey = context.fieldName(DRAFT_READONLY_CONTEXT); + CdsData readOnlyData = (CdsData) data.get(readonlyKey); if (Objects.nonNull(readOnlyData)) { - data.put(Attachments.CONTENT_ID, readOnlyData.get(Attachments.CONTENT_ID)); - data.put(Attachments.STATUS, readOnlyData.get(Attachments.STATUS)); - data.put(Attachments.SCANNED_AT, readOnlyData.get(Attachments.SCANNED_AT)); - data.remove(DRAFT_READONLY_CONTEXT); + data.put(context.fieldName(Attachments.CONTENT_ID), readOnlyData.get(Attachments.CONTENT_ID)); + data.put(context.fieldName(Attachments.STATUS), readOnlyData.get(Attachments.STATUS)); + data.put(context.fieldName(Attachments.SCANNED_AT), readOnlyData.get(Attachments.SCANNED_AT)); + if (readOnlyData.get(MediaData.FILE_NAME) != null) { + data.put(context.fieldName(MediaData.FILE_NAME), readOnlyData.get(MediaData.FILE_NAME)); + } + data.remove(readonlyKey); } } diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/AttachmentValidationHelper.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/AttachmentValidationHelper.java index 72df1920b..9cca79201 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/AttachmentValidationHelper.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/AttachmentValidationHelper.java @@ -39,7 +39,7 @@ public static void validateMediaAttachments( CdsModel cdsModel = cdsRuntime.getCdsModel(); List mediaEntityNames = - ApplicationHandlerHelper.isMediaEntity(entity) + ApplicationHandlerHelper.isDirectMediaEntity(entity) ? List.of(entity.getQualifiedName()) : cascader.findMediaEntityNames(cdsModel, entity); diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/MediaTypeResolver.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/MediaTypeResolver.java index b96d2e4c9..58e7a823e 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/MediaTypeResolver.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/MediaTypeResolver.java @@ -41,7 +41,8 @@ private static Optional> fetchAcceptableMediaTypes(CdsEntity entity public static Optional> getAcceptableMediaTypesAnnotation( CdsEntity entity) { - return Optional.ofNullable(entity.getElement(CONTENT_ELEMENT)) + return entity + .findElement(CONTENT_ELEMENT) .flatMap(element -> element.findAnnotation(ACCEPTABLE_MEDIA_TYPES_ANNOTATION)); } diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/CreateAttachmentEvent.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/CreateAttachmentEvent.java index 284ffa127..57e1c3904 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/CreateAttachmentEvent.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/CreateAttachmentEvent.java @@ -10,6 +10,7 @@ import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.MediaData; import com.sap.cds.feature.attachments.handler.applicationservice.transaction.ListenerProvider; import com.sap.cds.feature.attachments.handler.common.ApplicationHandlerHelper; +import com.sap.cds.feature.attachments.handler.common.AttachmentContext; import com.sap.cds.feature.attachments.service.AttachmentService; import com.sap.cds.feature.attachments.service.model.service.AttachmentModificationResult; import com.sap.cds.feature.attachments.service.model.service.CreateAttachmentInput; @@ -51,24 +52,31 @@ public CreateAttachmentEvent( @Override public InputStream processEvent( - Path path, InputStream content, Attachments attachment, EventContext eventContext) { + Path path, + InputStream content, + Attachments attachment, + EventContext eventContext, + AttachmentContext context) { logger.debug( "Calling attachment service with create event for entity {}", path.target().entity().getQualifiedName()); Map values = path.target().values(); Map keys = ApplicationHandlerHelper.removeDraftKey(path.target().keys()); - Optional mimeTypeOptional = getFieldValue(MediaData.MIME_TYPE, values, attachment); - Optional fileNameOptional = getFieldValue(MediaData.FILE_NAME, values, attachment); + + Optional fileNameOptional = + getFieldValue(MediaData.FILE_NAME, values, attachment, context); + Optional mimeTypeOptional = + getFieldValue(MediaData.MIME_TYPE, values, attachment, context); // Fall back to HTTP headers when values are not set in payload if (eventContext.getParameterInfo() != null) { if (fileNameOptional.isEmpty()) { fileNameOptional = extractFileNameFromHeader(eventContext); - fileNameOptional.ifPresent(fn -> values.put(MediaData.FILE_NAME, fn)); + fileNameOptional.ifPresent(fn -> values.put(context.fieldName(MediaData.FILE_NAME), fn)); } if (mimeTypeOptional.isEmpty()) { mimeTypeOptional = extractMimeTypeFromHeader(eventContext); - mimeTypeOptional.ifPresent(mt -> values.put(MediaData.MIME_TYPE, mt)); + mimeTypeOptional.ifPresent(mt -> values.put(context.fieldName(MediaData.MIME_TYPE), mt)); } } @@ -78,22 +86,30 @@ public InputStream processEvent( path.target().entity(), fileNameOptional.orElse(null), mimeTypeOptional.orElse(null), - content); + content, + context); AttachmentModificationResult result = attachmentService.createAttachment(createEventInput); ChangeSetListener createListener = listenerProvider.provideListener(result.contentId(), eventContext.getCdsRuntime()); eventContext.getChangeSetContext().register(createListener); - path.target().values().put(Attachments.CONTENT_ID, result.contentId()); - path.target().values().put(Attachments.STATUS, result.status()); + path.target().values().put(context.fieldName(Attachments.CONTENT_ID), result.contentId()); + path.target().values().put(context.fieldName(Attachments.STATUS), result.status()); if (nonNull(result.scannedAt())) { - path.target().values().put(Attachments.SCANNED_AT, result.scannedAt()); + path.target().values().put(context.fieldName(Attachments.SCANNED_AT), result.scannedAt()); } return result.isInternalStored() ? content : null; } private static Optional getFieldValue( - String fieldName, Map values, Attachments attachment) { + String fieldName, + Map values, + Attachments attachment, + AttachmentContext context) { + if (context.isInline()) { + Object prefixedValue = values.get(context.fieldName(fieldName)); + if (nonNull(prefixedValue)) return Optional.of((String) prefixedValue); + } Object annotationValue = values.get(fieldName); Object value = nonNull(annotationValue) ? annotationValue : attachment.get(fieldName); return Optional.ofNullable((String) value); diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/DoNothingAttachmentEvent.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/DoNothingAttachmentEvent.java index b409d274a..43b09bc7e 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/DoNothingAttachmentEvent.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/DoNothingAttachmentEvent.java @@ -4,6 +4,7 @@ package com.sap.cds.feature.attachments.handler.applicationservice.modifyevents; import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments; +import com.sap.cds.feature.attachments.handler.common.AttachmentContext; import com.sap.cds.ql.cqn.Path; import com.sap.cds.services.EventContext; import java.io.InputStream; @@ -20,7 +21,11 @@ public class DoNothingAttachmentEvent implements ModifyAttachmentEvent { @Override public InputStream processEvent( - Path path, InputStream content, Attachments attachment, EventContext eventContext) { + Path path, + InputStream content, + Attachments attachment, + EventContext eventContext, + AttachmentContext context) { logger.debug("Do nothing event for entity {}", path.target().entity().getQualifiedName()); return content; diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/MarkAsDeletedAttachmentEvent.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/MarkAsDeletedAttachmentEvent.java index c1316ee9f..1e4a817f3 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/MarkAsDeletedAttachmentEvent.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/MarkAsDeletedAttachmentEvent.java @@ -7,6 +7,8 @@ import static java.util.Objects.requireNonNull; import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments; +import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.MediaData; +import com.sap.cds.feature.attachments.handler.common.AttachmentContext; import com.sap.cds.feature.attachments.service.AttachmentService; import com.sap.cds.feature.attachments.service.model.service.MarkAsDeletedInput; import com.sap.cds.ql.cqn.Path; @@ -33,7 +35,11 @@ public MarkAsDeletedAttachmentEvent(AttachmentService attachmentService) { @Override public InputStream processEvent( - Path path, InputStream content, Attachments attachment, EventContext eventContext) { + Path path, + InputStream content, + Attachments attachment, + EventContext eventContext, + AttachmentContext context) { String qualifiedName = eventContext.getTarget().getQualifiedName(); logger.debug( "Processing the event for calling attachment service with mark as delete event for entity {}", @@ -51,12 +57,20 @@ public InputStream processEvent( qualifiedName); } if (nonNull(path)) { - String newContentId = (String) path.target().values().get(Attachments.CONTENT_ID); - if (nonNull(newContentId) && newContentId.equals(attachment.getContentId()) - || !path.target().values().containsKey(Attachments.CONTENT_ID)) { - path.target().values().put(Attachments.CONTENT_ID, null); - path.target().values().put(Attachments.STATUS, null); - path.target().values().put(Attachments.SCANNED_AT, null); + String newContentId = + (String) path.target().values().get(context.fieldName(Attachments.CONTENT_ID)); + boolean replacedBySameContent = + nonNull(newContentId) && newContentId.equals(attachment.getContentId()); + boolean noNewContentSupplied = + !path.target().values().containsKey(context.fieldName(Attachments.CONTENT_ID)); + if (replacedBySameContent || noNewContentSupplied) { + path.target().values().put(context.fieldName(Attachments.CONTENT_ID), null); + path.target().values().put(context.fieldName(Attachments.STATUS), null); + path.target().values().put(context.fieldName(Attachments.SCANNED_AT), null); + if (context.isInline()) { + path.target().values().put(context.fieldName(MediaData.MIME_TYPE), null); + path.target().values().put(context.fieldName(MediaData.FILE_NAME), null); + } } } return content; diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/ModifyAttachmentEvent.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/ModifyAttachmentEvent.java index 1a830b5c2..bc2f76799 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/ModifyAttachmentEvent.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/ModifyAttachmentEvent.java @@ -4,6 +4,7 @@ package com.sap.cds.feature.attachments.handler.applicationservice.modifyevents; import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments; +import com.sap.cds.feature.attachments.handler.common.AttachmentContext; import com.sap.cds.feature.attachments.service.AttachmentService; import com.sap.cds.ql.cqn.Path; import com.sap.cds.services.EventContext; @@ -22,8 +23,13 @@ public interface ModifyAttachmentEvent { * @param content the content of the attachment * @param attachment existing attachment data * @param eventContext the current event context + * @param context the attachment context describing how to address this attachment's fields * @return the processed content */ InputStream processEvent( - Path path, InputStream content, Attachments attachment, EventContext eventContext); + Path path, + InputStream content, + Attachments attachment, + EventContext eventContext, + AttachmentContext context); } diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/UpdateAttachmentEvent.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/UpdateAttachmentEvent.java index a178be89d..c0961c20f 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/UpdateAttachmentEvent.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/UpdateAttachmentEvent.java @@ -6,6 +6,7 @@ import static java.util.Objects.requireNonNull; import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments; +import com.sap.cds.feature.attachments.handler.common.AttachmentContext; import com.sap.cds.feature.attachments.service.AttachmentService; import com.sap.cds.ql.cqn.Path; import com.sap.cds.services.EventContext; @@ -34,12 +35,16 @@ public UpdateAttachmentEvent( @Override public InputStream processEvent( - Path path, InputStream content, Attachments attachment, EventContext eventContext) { + Path path, + InputStream content, + Attachments attachment, + EventContext eventContext, + AttachmentContext context) { logger.debug( "Processing UPDATE event by calling attachment service with create and delete event for entity {}", path.target().entity().getQualifiedName()); - deleteEvent.processEvent(path, content, attachment, eventContext); - return createEvent.processEvent(path, content, attachment, eventContext); + deleteEvent.processEvent(path, content, attachment, eventContext, context); + return createEvent.processEvent(path, content, attachment, eventContext, context); } } diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/readhelper/BeforeReadItemsModifier.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/readhelper/BeforeReadItemsModifier.java index e5b16ab7b..3e46b17d6 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/readhelper/BeforeReadItemsModifier.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/readhelper/BeforeReadItemsModifier.java @@ -10,6 +10,7 @@ import com.sap.cds.ql.cqn.CqnSelectListItem; import com.sap.cds.ql.cqn.Modifier; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -25,9 +26,16 @@ public class BeforeReadItemsModifier implements Modifier { private static final String ROOT_ASSOCIATION = ""; private final List mediaAssociations; + private final List inlineAttachmentPrefixes; public BeforeReadItemsModifier(List mediaAssociations) { + this(mediaAssociations, Collections.emptyList()); + } + + public BeforeReadItemsModifier( + List mediaAssociations, List inlineAttachmentPrefixes) { this.mediaAssociations = mediaAssociations; + this.inlineAttachmentPrefixes = inlineAttachmentPrefixes; } @Override @@ -79,6 +87,30 @@ private void enhanceWithNewFieldForMediaAssociation( listToEnhance.add(CQL.get(Attachments.STATUS)); listToEnhance.add(CQL.get(Attachments.SCANNED_AT)); } + // Also add inline attachment prefixed fields, but only when the content field + // is explicitly selected (mirroring the composition-based guard above). + // When the items list is empty or contains only a star (SELECT *), all columns + // are already included, so adding explicit columns would break the query by + // replacing SELECT * with a partial column list. + if (ROOT_ASSOCIATION.equals(association)) { + for (String prefix : inlineAttachmentPrefixes) { + String prefixedContent = prefix + "_" + MediaData.CONTENT; + String prefixedContentId = prefix + "_" + Attachments.CONTENT_ID; + String prefixedStatus = prefix + "_" + Attachments.STATUS; + String prefixedScannedAt = prefix + "_" + Attachments.SCANNED_AT; + if (list.stream().anyMatch(item -> isItemRefFieldWithName(item, prefixedContent)) + && list.stream().noneMatch(item -> isItemRefFieldWithName(item, prefixedContentId))) { + logger.debug( + "Adding inline attachment fields: {}, {} and {}", + prefixedContentId, + prefixedStatus, + prefixedScannedAt); + listToEnhance.add(CQL.get(prefixedContentId)); + listToEnhance.add(CQL.get(prefixedStatus)); + listToEnhance.add(CQL.get(prefixedScannedAt)); + } + } + } } private boolean isMediaAssociationAndNeedNewContentIdField( diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/ApplicationHandlerHelper.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/ApplicationHandlerHelper.java index c8308f513..a79f3e132 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/ApplicationHandlerHelper.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/ApplicationHandlerHelper.java @@ -10,13 +10,17 @@ import com.sap.cds.CdsDataProcessor.Filter; import com.sap.cds.CdsDataProcessor.Validator; import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments; +import com.sap.cds.reflect.CdsElement; import com.sap.cds.reflect.CdsEntity; import com.sap.cds.reflect.CdsStructuredType; import com.sap.cds.services.draft.Drafts; import java.util.ArrayList; +import java.util.Comparator; import java.util.HashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.concurrent.atomic.AtomicBoolean; /** @@ -27,14 +31,29 @@ 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"; + /** + * Internal marker key used to store the inline attachment prefix in extracted attachment data. + * This enables correct matching of inline attachments when operating on multiple inline + * attachments on the same entity. Package-private; only used by {@link AttachmentContext.Inline} + * for matching. + */ + static final String INLINE_PREFIX_MARKER = "_inlinePrefix"; + /** * A filter for media content fields. The filter checks if the entity is a media entity and if the - * element has the annotation "Core.MediaType". + * element has the annotation "Core.MediaType". Also supports inline attachment type fields where + * the structured type is flattened into the parent entity. */ public static final Filter MEDIA_CONTENT_FILTER = - (path, element, type) -> - isMediaEntity(path.target().type()) - && element.findAnnotation(ANNOTATION_CORE_MEDIA_TYPE).isPresent(); + (path, element, type) -> { + // Case 1: Composition-based attachment entity (existing behavior) + if (path.target().type().getAnnotationValue(ANNOTATION_IS_MEDIA_DATA, false) + && element.findAnnotation(ANNOTATION_CORE_MEDIA_TYPE).isPresent()) { + return true; + } + // Case 2: Inline attachment type field (flattened into parent entity) + return isInlineAttachmentContentField(path.target().type(), element); + }; /** * Checks if the data contains a content field. @@ -53,15 +72,105 @@ public static boolean containsContentField(CdsEntity entity, Listtrue if the entity is a media entity, false otherwise */ public static boolean isMediaEntity(CdsStructuredType baseEntity) { + return isDirectMediaEntity(baseEntity) || hasInlineAttachmentElements(baseEntity); + } + + /** + * Checks if the entity is directly annotated as a media entity (without considering inline + * elements). Used for composition-based attachment detection. + * + * @param baseEntity The entity to check + * @return true if the entity itself has the annotation + */ + public static boolean isDirectMediaEntity(CdsStructuredType baseEntity) { return baseEntity.getAnnotationValue(ANNOTATION_IS_MEDIA_DATA, false); } + /** + * Checks if the entity has inline attachment elements. In the flattened CDS model, these appear + * as elements with the annotation "_is_media_data" on the element itself, where the entity is not + * directly annotated as a media entity. The flattened element names follow the pattern + * "prefix_content", "prefix_contentId", etc. + * + * @param entity The entity to check + * @return true if inline attachment elements exist + */ + public static boolean hasInlineAttachmentElements(CdsStructuredType entity) { + return !isDirectMediaEntity(entity) && !getInlineAttachmentFieldNames(entity).isEmpty(); + } + + /** + * Returns the inline attachment element name prefixes for a given entity. In the flattened CDS + * model, inline attachment fields appear as "prefix_content", "prefix_contentId", etc. with + * element-level "_is_media_data" annotation. This method finds all unique prefixes by looking for + * elements ending with "_content" that have the annotation. + * + * @param entity The entity to inspect + * @return list of inline attachment field name prefixes (e.g. ["profilePicture"]) + */ + public static List getInlineAttachmentFieldNames(CdsStructuredType entity) { + var elements = entity.elements(); + if (elements == null) return List.of(); + String contentSuffix = "_content"; + LinkedHashSet fieldNames = new LinkedHashSet<>(); + elements + .filter(e -> e.getName().endsWith(contentSuffix)) + .filter(e -> e.getAnnotationValue(ANNOTATION_IS_MEDIA_DATA, false)) + .filter(e -> e.findAnnotation(ANNOTATION_CORE_MEDIA_TYPE).isPresent()) + .forEach( + e -> { + String prefix = + e.getName().substring(0, e.getName().length() - contentSuffix.length()); + if (!prefix.isEmpty()) { + fieldNames.add(prefix); + } + }); + return new ArrayList<>(fieldNames); + } + + /** + * Checks if an element is a flattened content field from an inline Attachment type. For example, + * "profilePicture_content" where "profilePicture" is of type Attachment. In the flattened model, + * this is an element that ends with "_content", has the "_is_media_data" annotation, and has the + * "Core.MediaType" annotation. + * + * @param entity The parent entity + * @param element The element to check + * @return true if the element is an inline attachment content field + */ + public static boolean isInlineAttachmentContentField( + CdsStructuredType entity, CdsElement element) { + if (isDirectMediaEntity(entity)) { + return false; + } + String elementName = element.getName(); + return elementName.endsWith("_content") + && element.getAnnotationValue(ANNOTATION_IS_MEDIA_DATA, false) + && element.findAnnotation(ANNOTATION_CORE_MEDIA_TYPE).isPresent(); + } + + /** + * Finds the inline attachment prefix for a given flattened element name. For example, given + * "profilePicture_content", returns Optional of "profilePicture". Uses the known inline prefixes + * from the entity to match against the element name. + * + * @param entity The parent entity + * @param elementName The flattened element name + * @return Optional containing the prefix, or empty if not an inline attachment field + */ + public static Optional getInlineAttachmentPrefix( + CdsStructuredType entity, String elementName) { + return getInlineAttachmentFieldNames(entity).stream() + .filter(prefix -> elementName.startsWith(prefix + "_")) + .max(Comparator.comparingInt(String::length)); + } + /** * Extracts key fields from CdsData based on the entity definition. * @@ -86,6 +195,7 @@ public static Map extractKeys(CdsData data, CdsEntity entity) { /** * Condenses the attachments from the given data into a list of {@link Attachments attachments}. + * Supports both composition-based and inline attachment type fields. * * @param data the list of {@link CdsData} to process * @param entity the {@link CdsEntity entity} type of the given data @@ -96,12 +206,57 @@ public static List condenseAttachments( List resultList = new ArrayList<>(); Validator validator = - (path, element, value) -> resultList.add(Attachments.of(path.target().values())); + (path, element, value) -> { + // For composition-based: path.target() is the attachment entity + if (isDirectMediaEntity(path.target().type())) { + resultList.add(Attachments.of(path.target().values())); + } else { + // For inline type: extract prefixed fields from parent entity + Optional prefix = + getInlineAttachmentPrefix(path.target().type(), element.getName()); + if (prefix.isPresent()) { + Attachments attachment = + extractInlineAttachment(path.target().values(), prefix.get()); + // Avoid duplicates (same prefix already processed) + if (resultList.stream() + .noneMatch( + existing -> + nonNull(existing.getContentId()) + && existing.getContentId().equals(attachment.getContentId()))) { + resultList.add(attachment); + } + } + } + }; CdsDataProcessor.create().addValidator(MEDIA_CONTENT_FILTER, validator).process(data, entity); return resultList; } + /** + * Extracts inline attachment data from a parent entity's values by stripping the prefix. For + * example, from "profilePicture_contentId" extracts "contentId". + * + * @param parentValues the parent entity values map + * @param prefix the inline field prefix (e.g. "profilePicture") + * @return an Attachments object with the extracted values + */ + public static Attachments extractInlineAttachment( + Map parentValues, String prefix) { + Attachments attachment = Attachments.create(); + String prefixWithUnderscore = prefix + "_"; + parentValues.forEach( + (key, value) -> { + if (key.startsWith(prefixWithUnderscore)) { + String logicalName = key.substring(prefixWithUnderscore.length()); + attachment.put(logicalName, value); + } + }); + // Store the inline prefix so we can match later + attachment.put(INLINE_PREFIX_MARKER, prefix); + return attachment; + } + public static boolean areKeysInData(Map keys, CdsData data) { return keys.entrySet().stream() .allMatch( diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/AssociationCascader.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/AssociationCascader.java index 43fdcb39d..5dedb4acb 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/AssociationCascader.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/AssociationCascader.java @@ -33,7 +33,7 @@ public List findMediaEntityNames(CdsModel model, CdsEntity entity) { public List findMediaAssociationNames(CdsModel model, CdsEntity entity) { List result = new ArrayList<>(); - if (ApplicationHandlerHelper.isMediaEntity(entity)) { + if (ApplicationHandlerHelper.isDirectMediaEntity(entity)) { result.add(""); } NodeTree tree = findEntityPath(model, entity); @@ -93,17 +93,27 @@ private List> getAttachmentAssociationPath( var currentList = new LinkedList(); var localProcessEntities = new ArrayList(); - var isMediaEntity = ApplicationHandlerHelper.isMediaEntity(entity); - if (isMediaEntity) { + var isDirectMediaEntity = ApplicationHandlerHelper.isDirectMediaEntity(entity); + var hasInlineAttachments = ApplicationHandlerHelper.hasInlineAttachmentElements(entity); + + // Direct media entities (e.g. Attachments) are always leaf nodes + // No need to traverse compositions + if (isDirectMediaEntity) { var identifier = new AssociationIdentifier(associationName, entity.getQualifiedName()); firstList.addLast(identifier); - } - - if (isMediaEntity) { internalResultList.add(firstList); return internalResultList; } + // Entities with inline attachment fields (e.g. Items with receipt : Attachment) + // are treated as media entities, but may also have compositions that need traversal. + // Record this entity as a path AND continue to discover child compositions. + if (hasInlineAttachments) { + var inlinePath = new LinkedList<>(firstList); + inlinePath.addLast(new AssociationIdentifier(associationName, entity.getQualifiedName())); + internalResultList.add(inlinePath); + } + Map associations = entity .elements() diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/AttachmentContext.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/AttachmentContext.java new file mode 100644 index 000000000..94b6dae0c --- /dev/null +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/AttachmentContext.java @@ -0,0 +1,114 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. + */ +package com.sap.cds.feature.attachments.handler.common; + +import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments; +import com.sap.cds.reflect.CdsElement; +import com.sap.cds.reflect.CdsStructuredType; +import java.util.Map; +import java.util.Optional; + +/** + * Encapsulates how to address one attachment's fields regardless of whether it resides in a + * composition child entity (composition-based) or is flattened into the parent entity (inline). + * + *

The plugin processes one attachment at a time. {@code AttachmentContext} describes how to read + * and write that attachment's fields in the data map, and how to match it against existing + * attachment data. + */ +public sealed interface AttachmentContext { + + /** + * Resolves a logical attachment field name (e.g. {@code "contentId"}) to the actual key in the + * data map (e.g. {@code "avatar_contentId"} for inline, or {@code "contentId"} for composition). + */ + String fieldName(String logicalName); + + /** + * Returns {@code true} if the given existing attachment record corresponds to this context's + * attachment. + * + * @param existing the existing attachment data to check + * @param parentKeys the parent entity keys from the current path + */ + boolean matches(Attachments existing, Map parentKeys); + + /** Returns {@code true} if this is an inline attachment (flattened into the parent entity). */ + boolean isInline(); + + /** + * Extracts an {@link Attachments} view from the given data map. For composition-based + * attachments, wraps the values directly. For inline attachments, strips the prefix from flat + * keys to produce a logical attachment view. + * + * @param values the data map (either the attachment entity row or the parent entity row) + * @return an {@link Attachments} object with logical field names + */ + Attachments extractFrom(Map values); + + /** + * Determines the correct {@link AttachmentContext} from a {@code CdsDataProcessor} callback. + * + * @param entity the entity type at the current processing path + * @param element the element that matched the media content filter + * @return the appropriate context implementation + */ + static AttachmentContext from(CdsStructuredType entity, CdsElement element) { + Optional prefix = + ApplicationHandlerHelper.getInlineAttachmentPrefix(entity, element.getName()); + return prefix.map(Inline::new).orElseGet(Composition::new); + } + + /** Context for composition-based attachments where the attachment is its own entity. */ + record Composition() implements AttachmentContext { + + @Override + public String fieldName(String logicalName) { + return logicalName; + } + + @Override + public boolean matches(Attachments existing, Map parentKeys) { + return ApplicationHandlerHelper.areKeysInData(parentKeys, existing); + } + + @Override + public boolean isInline() { + return false; + } + + @Override + public Attachments extractFrom(Map values) { + return Attachments.of(values); + } + } + + /** + * Context for inline attachments where the structured type fields are flattened into the parent + * entity with a prefix (e.g. {@code "avatar_content"}, {@code "avatar_contentId"}). + */ + record Inline(String prefix) implements AttachmentContext { + + @Override + public String fieldName(String logicalName) { + return prefix + "_" + logicalName; + } + + @Override + public boolean matches(Attachments existing, Map parentKeys) { + String existingPrefix = (String) existing.get(ApplicationHandlerHelper.INLINE_PREFIX_MARKER); + return prefix.equals(existingPrefix); + } + + @Override + public boolean isInline() { + return true; + } + + @Override + public Attachments extractFrom(Map values) { + return ApplicationHandlerHelper.extractInlineAttachment(values, prefix); + } + } +} diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/AttachmentsReader.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/AttachmentsReader.java index 136d32c7d..2a1a06594 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/AttachmentsReader.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/AttachmentsReader.java @@ -12,8 +12,10 @@ import com.sap.cds.ql.Select; import com.sap.cds.ql.StructuredType; import com.sap.cds.ql.cqn.CqnFilterableStatement; +import com.sap.cds.ql.cqn.CqnSelectListItem; import com.sap.cds.reflect.CdsEntity; import com.sap.cds.reflect.CdsModel; +import com.sap.cds.services.draft.Drafts; import com.sap.cds.services.persistence.PersistenceService; import java.util.ArrayList; import java.util.List; @@ -44,17 +46,25 @@ public List readAttachments( logger.debug("Start reading attachments for entity {}", entity.getQualifiedName()); NodeTree nodePath = cascader.findEntityPath(model, entity); - List> expandList = buildExpandList(nodePath); + List> expandList = buildExpandList(model, nodePath); - if (expandList.isEmpty() && !ApplicationHandlerHelper.isMediaEntity(entity)) { + List inlineColumns = buildInlineAttachmentColumns(entity); + + if (expandList.isEmpty() + && inlineColumns.isEmpty() + && !ApplicationHandlerHelper.isMediaEntity(entity)) { logResultData(entity, List.of()); return List.of(); } - Select select = - !expandList.isEmpty() - ? Select.from(statement.ref()).columns(expandList) - : Select.from(statement.ref()).columns(StructuredType::_all); + Select select; + if (!expandList.isEmpty() || !inlineColumns.isEmpty()) { + List allItems = new ArrayList<>(inlineColumns); + allItems.addAll(expandList); + select = Select.from(statement.ref()).columns(allItems); + } else { + select = Select.from(statement.ref()).columns(StructuredType::_all); + } statement.where().ifPresent(select::where); Result result = persistence.run(select); @@ -63,18 +73,52 @@ public List readAttachments( return attachments; } - private List> buildExpandList(NodeTree root) { + private List buildInlineAttachmentColumns(CdsEntity entity) { + List inlineFields = ApplicationHandlerHelper.getInlineAttachmentFieldNames(entity); + List columns = new ArrayList<>(); + for (String fieldName : inlineFields) { + // Include the content field so CdsDataProcessor's MEDIA_CONTENT_FILTER can match it + columns.add(CQL.get(fieldName + "_content")); + columns.add(CQL.get(fieldName + "_" + Attachments.CONTENT_ID)); + columns.add(CQL.get(fieldName + "_" + Attachments.STATUS)); + } + if (!columns.isEmpty()) { + entity.keyElements().forEach(keyElement -> columns.add(CQL.get(keyElement.getName()))); + if (entity.findElement(Drafts.HAS_ACTIVE_ENTITY).isPresent()) { + columns.add(CQL.get(Drafts.HAS_ACTIVE_ENTITY)); + } + } + return columns; + } + + private List> buildExpandList(CdsModel model, NodeTree root) { List> expandResultList = new ArrayList<>(); - root.getChildren().forEach(child -> expandResultList.add(buildExpandFromTree(child))); + root.getChildren().forEach(child -> expandResultList.add(buildExpandFromTree(model, child))); return expandResultList; } - private Expand buildExpandFromTree(NodeTree node) { - return node.getChildren().isEmpty() + private Expand buildExpandFromTree(CdsModel model, NodeTree node) { + // Look up the entity for this node to check for inline attachments + CdsEntity nodeEntity = model.findEntity(node.getIdentifier().fullEntityName()).orElse(null); + + // Build inline attachment columns for this child entity if it has any + List inlineColumns = + nodeEntity != null ? buildInlineAttachmentColumns(nodeEntity) : List.of(); + + // Build child expands recursively + List childExpands = new ArrayList<>(); + for (NodeTree child : node.getChildren()) { + childExpands.add(buildExpandFromTree(model, child)); + } + + // Combine inline columns and child expands + List expandItems = new ArrayList<>(inlineColumns); + expandItems.addAll(childExpands); + + return expandItems.isEmpty() ? CQL.to(node.getIdentifier().associationName()).expand() - : CQL.to(node.getIdentifier().associationName()) - .expand(node.getChildren().stream().map(this::buildExpandFromTree).toList()); + : CQL.to(node.getIdentifier().associationName()).expand(expandItems); } private static void logResultData(CdsEntity entity, List attachments) { diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/draftservice/DraftCancelAttachmentsHandler.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/draftservice/DraftCancelAttachmentsHandler.java index 82978dcf0..ad4ba623f 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/draftservice/DraftCancelAttachmentsHandler.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/draftservice/DraftCancelAttachmentsHandler.java @@ -12,9 +12,11 @@ import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments; import com.sap.cds.feature.attachments.handler.applicationservice.modifyevents.MarkAsDeletedAttachmentEvent; import com.sap.cds.feature.attachments.handler.common.ApplicationHandlerHelper; +import com.sap.cds.feature.attachments.handler.common.AttachmentContext; import com.sap.cds.feature.attachments.handler.common.AttachmentsReader; import com.sap.cds.ql.CQL; import com.sap.cds.ql.cqn.CqnDelete; +import com.sap.cds.ql.cqn.Path; import com.sap.cds.reflect.CdsEntity; import com.sap.cds.reflect.CdsStructuredType; import com.sap.cds.services.draft.DraftCancelEventContext; @@ -26,6 +28,7 @@ import com.sap.cds.services.handler.annotations.ServiceName; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -41,9 +44,21 @@ public class DraftCancelAttachmentsHandler implements EventHandler { private static final Logger logger = LoggerFactory.getLogger(DraftCancelAttachmentsHandler.class); private static final Filter contentIdFilter = - (path, element, type) -> - ApplicationHandlerHelper.isMediaEntity(path.target().type()) - && element.getName().equals(Attachments.CONTENT_ID); + (path, element, type) -> { + // Case 1: Composition-based attachment entity + if (ApplicationHandlerHelper.isDirectMediaEntity(path.target().type()) + && element.getName().equals(Attachments.CONTENT_ID)) { + return true; + } + // Case 2: Inline attachment type — check for prefixed contentId + String elementName = element.getName(); + if (elementName.endsWith("_" + Attachments.CONTENT_ID)) { + return ApplicationHandlerHelper.getInlineAttachmentPrefix( + path.target().type(), elementName) + .isPresent(); + } + return false; + }; private final AttachmentsReader attachmentsReader; private final MarkAsDeletedAttachmentEvent deleteEvent; @@ -88,25 +103,45 @@ void processBeforeDraftCancel(DraftCancelEventContext context) { private Validator buildDeleteContentValidator( DraftCancelEventContext context, List activeCondensedAttachments) { return (path, element, value) -> { - Attachments attachment = Attachments.of(path.target().values()); + AttachmentContext attachmentCtx = AttachmentContext.from(path.target().type(), element); + Attachments attachment = extractAttachmentFromPath(path, attachmentCtx); + if (Boolean.FALSE.equals(attachment.get(Drafts.HAS_ACTIVE_ENTITY))) { - deleteEvent.processEvent(path, null, attachment, context); + deleteEvent.processEvent(path, null, attachment, context, attachmentCtx); return; } + Map keys = ApplicationHandlerHelper.removeDraftKey(path.target().keys()); Optional existingEntry = activeCondensedAttachments.stream() - .filter(updatedData -> ApplicationHandlerHelper.areKeysInData(keys, updatedData)) + .filter(updatedData -> attachmentCtx.matches(Attachments.of(updatedData), keys)) .findAny(); - existingEntry.ifPresent( - entry -> { - if (!entry.get(Attachments.CONTENT_ID).equals(value)) { - deleteEvent.processEvent(null, null, attachment, context); - } - }); + + if (existingEntry.isPresent()) { + Object existingContentId = existingEntry.get().get(Attachments.CONTENT_ID); + if (!Objects.equals(existingContentId, attachment.getContentId())) { + deleteEvent.processEvent(null, null, attachment, context, attachmentCtx); + } + } else if (attachment.getContentId() != null) { + logger.warn( + "Draft attachment with contentId {} has no matching active entry. Deleting to prevent orphan.", + attachment.getContentId()); + deleteEvent.processEvent(null, null, attachment, context, attachmentCtx); + } }; } + private Attachments extractAttachmentFromPath(Path path, AttachmentContext attachmentCtx) { + Attachments attachment = attachmentCtx.extractFrom(path.target().values()); + if (attachmentCtx.isInline()) { + Object hasActiveEntity = path.target().values().get(Drafts.HAS_ACTIVE_ENTITY); + if (hasActiveEntity != null) { + attachment.put(Drafts.HAS_ACTIVE_ENTITY, hasActiveEntity); + } + } + return attachment; + } + private List readAttachments( DraftCancelEventContext context, CdsStructuredType entity, boolean isActiveEntity) { logger.debug( diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/draftservice/DraftPatchAttachmentsHandler.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/draftservice/DraftPatchAttachmentsHandler.java index 5d66dbc96..b1477dd6b 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/draftservice/DraftPatchAttachmentsHandler.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/draftservice/DraftPatchAttachmentsHandler.java @@ -10,11 +10,18 @@ import com.sap.cds.CdsDataProcessor.Converter; import com.sap.cds.Result; import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments; +import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.MediaData; import com.sap.cds.feature.attachments.handler.applicationservice.helper.ModifyApplicationHandlerHelper; import com.sap.cds.feature.attachments.handler.applicationservice.modifyevents.ModifyAttachmentEventFactory; import com.sap.cds.feature.attachments.handler.common.ApplicationHandlerHelper; +import com.sap.cds.feature.attachments.handler.common.AttachmentContext; +import com.sap.cds.ql.CQL; import com.sap.cds.ql.Select; +import com.sap.cds.ql.Update; +import com.sap.cds.ql.cqn.CqnPredicate; import com.sap.cds.ql.cqn.CqnSelect; +import com.sap.cds.ql.cqn.CqnUpdate; +import com.sap.cds.reflect.CdsElement; import com.sap.cds.reflect.CdsEntity; import com.sap.cds.services.draft.DraftPatchEventContext; import com.sap.cds.services.draft.DraftService; @@ -24,7 +31,10 @@ import com.sap.cds.services.handler.annotations.ServiceName; import com.sap.cds.services.persistence.PersistenceService; import java.io.InputStream; +import java.util.HashMap; import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -63,17 +73,91 @@ void processBeforeDraftPatch(DraftPatchEventContext context, List existingAttachments; + if (attachmentCtx.isInline()) { + // For inline attachments, the DB result has flattened column names (e.g. + // profileIcon_contentId). + // Extract to unprefixed Attachments and carry over parent entity keys for matching. + String prefix = ((AttachmentContext.Inline) attachmentCtx).prefix(); + Map parentKeys = path.target().keys(); + existingAttachments = + result.listOf(Attachments.class).stream() + .map( + raw -> { + Attachments extracted = + ApplicationHandlerHelper.extractInlineAttachment(raw, prefix); + parentKeys.forEach(extracted::putIfAbsent); + return extracted; + }) + .collect(Collectors.toList()); + } else { + existingAttachments = result.listOf(Attachments.class); + } + return ModifyApplicationHandlerHelper.handleAttachmentForEntity( - result.listOf(Attachments.class), + existingAttachments, eventFactory, context, path, (InputStream) value, - defaultMaxSize); + defaultMaxSize, + attachmentCtx); }; CdsDataProcessor.create() .addConverter(ApplicationHandlerHelper.MEDIA_CONTENT_FILTER, converter) .process(data, context.getTarget()); + + // The framework's DRAFT_PATCH ON handler only persists readonly fields added by + // @Before handlers, so mimeType and fileName (non-readonly) set by CreateAttachmentEvent + // are dropped. Persist them directly via the PersistenceService. + persistInlineAttachmentMetadata(context.getTarget(), data); + } + + private void persistInlineAttachmentMetadata(CdsEntity target, List data) { + List inlinePrefixes = ApplicationHandlerHelper.getInlineAttachmentFieldNames(target); + if (inlinePrefixes.isEmpty()) { + return; + } + + CdsEntity draftEntity = DraftUtils.getDraftEntity(target); + for (CdsData d : data) { + for (String prefix : inlinePrefixes) { + String mimeTypeField = prefix + "_" + MediaData.MIME_TYPE; + String fileNameField = prefix + "_" + MediaData.FILE_NAME; + String contentIdField = prefix + "_" + Attachments.CONTENT_ID; + + // Only update if the attachment was actually processed (contentId present) + Object contentId = d.get(contentIdField); + if (contentId == null) { + continue; + } + + Map updateData = new HashMap<>(); + Object mimeType = d.get(mimeTypeField); + Object fileName = d.get(fileNameField); + if (mimeType != null) { + updateData.put(mimeTypeField, mimeType); + } + if (fileName != null) { + updateData.put(fileNameField, fileName); + } + if (updateData.isEmpty()) { + continue; + } + + CqnPredicate predicate = CQL.get(contentIdField).eq(contentId); + for (CdsElement key : target.keyElements().toList()) { + Object keyValue = d.get(key.getName()); + if (keyValue != null) { + predicate = CQL.and(predicate, CQL.get(key.getName()).eq(keyValue)); + } + } + CqnUpdate update = Update.entity(draftEntity).data(updateData).where(predicate); + persistence.run(update); + } + } } } diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/AttachmentsServiceImpl.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/AttachmentsServiceImpl.java index a33660351..0d1fda9e3 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/AttachmentsServiceImpl.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/AttachmentsServiceImpl.java @@ -4,6 +4,7 @@ package com.sap.cds.feature.attachments.service; import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.MediaData; +import com.sap.cds.feature.attachments.handler.common.AttachmentContext; import com.sap.cds.feature.attachments.service.model.service.AttachmentModificationResult; import com.sap.cds.feature.attachments.service.model.service.CreateAttachmentInput; import com.sap.cds.feature.attachments.service.model.service.MarkAsDeletedInput; @@ -50,6 +51,10 @@ public AttachmentModificationResult createAttachment(CreateAttachmentInput input mediaData.setMimeType(input.mimeType()); mediaData.setContent(input.content()); createContext.setData(mediaData); + AttachmentContext ctx = input.attachmentContext(); + if (ctx.isInline()) { + createContext.put("attachment.inlinePrefix", ((AttachmentContext.Inline) ctx).prefix()); + } emit(createContext); diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/handler/DefaultAttachmentsServiceHandler.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/handler/DefaultAttachmentsServiceHandler.java index c14217499..ff45233a1 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/handler/DefaultAttachmentsServiceHandler.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/handler/DefaultAttachmentsServiceHandler.java @@ -5,6 +5,7 @@ import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments; import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.StatusCode; +import com.sap.cds.feature.attachments.handler.common.AttachmentContext; import com.sap.cds.feature.attachments.service.AttachmentService; import com.sap.cds.feature.attachments.service.handler.transaction.EndTransactionMalwareScanProvider; import com.sap.cds.feature.attachments.service.model.servicehandler.AttachmentCreateEventContext; @@ -66,9 +67,12 @@ void createAttachment(AttachmentCreateEventContext context) { */ @After void afterCreateAttachment(AttachmentCreateEventContext context) { + String prefix = (String) context.get("attachment.inlinePrefix"); + AttachmentContext attachmentContext = + prefix != null ? new AttachmentContext.Inline(prefix) : new AttachmentContext.Composition(); ChangeSetListener listener = malwareScanProvider.getChangeSetListener( - context.getAttachmentEntity(), context.getContentId()); + context.getAttachmentEntity(), context.getContentId(), attachmentContext); context.getChangeSetContext().register(listener); } diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/handler/transaction/EndTransactionMalwareScanProvider.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/handler/transaction/EndTransactionMalwareScanProvider.java index da667e0d5..a96ea155d 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/handler/transaction/EndTransactionMalwareScanProvider.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/handler/transaction/EndTransactionMalwareScanProvider.java @@ -3,6 +3,7 @@ */ package com.sap.cds.feature.attachments.service.handler.transaction; +import com.sap.cds.feature.attachments.handler.common.AttachmentContext; import com.sap.cds.reflect.CdsEntity; import com.sap.cds.services.changeset.ChangeSetListener; @@ -17,7 +18,9 @@ public interface EndTransactionMalwareScanProvider { * * @param attachmentEntity The entity containing the attachment to scan * @param contentId The ID of the attachment content + * @param attachmentContext The context describing how to address the attachment's fields * @return The {@link ChangeSetListener} for the malware scan after the transaction is completed */ - ChangeSetListener getChangeSetListener(CdsEntity attachmentEntity, String contentId); + ChangeSetListener getChangeSetListener( + CdsEntity attachmentEntity, String contentId, AttachmentContext attachmentContext); } diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/handler/transaction/EndTransactionMalwareScanRunner.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/handler/transaction/EndTransactionMalwareScanRunner.java index a0f380fc6..f1e462dbe 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/handler/transaction/EndTransactionMalwareScanRunner.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/handler/transaction/EndTransactionMalwareScanRunner.java @@ -3,6 +3,7 @@ */ package com.sap.cds.feature.attachments.service.handler.transaction; +import com.sap.cds.feature.attachments.handler.common.AttachmentContext; import com.sap.cds.feature.attachments.service.malware.AsyncMalwareScanExecutor; import com.sap.cds.feature.attachments.service.malware.AttachmentMalwareScanner; import com.sap.cds.reflect.CdsEntity; @@ -22,12 +23,14 @@ * * @param attachmentEntity The attachment entity to be scanned * @param contentId The content ID of the attachment + * @param attachmentContext The context describing how to address the attachment's fields * @param attachmentMalwareScanner The attachment malware scanner to be used for scanning * @param runtime The runtime instance to be used for creating the request context */ public record EndTransactionMalwareScanRunner( CdsEntity attachmentEntity, String contentId, + AttachmentContext attachmentContext, AttachmentMalwareScanner attachmentMalwareScanner, CdsRuntime runtime) implements ChangeSetListener, AsyncMalwareScanExecutor { @@ -38,16 +41,18 @@ public record EndTransactionMalwareScanRunner( @Override public void afterClose(boolean completed) { if (completed) { - startScanning(attachmentEntity, contentId); + startScanning(attachmentEntity, contentId, attachmentContext); } } @Override - public void scanAsync(CdsEntity attachmentEntity, String contentId) { - startScanning(attachmentEntity, contentId); + public void scanAsync( + CdsEntity attachmentEntity, String contentId, AttachmentContext attachmentContext) { + startScanning(attachmentEntity, contentId, attachmentContext); } - private void startScanning(CdsEntity attachmentEntityToScan, String contentId) { + private void startScanning( + CdsEntity attachmentEntityToScan, String contentId, AttachmentContext context) { // get current request context RequestContextRunner runner = runtime.requestContext(); @@ -71,7 +76,7 @@ private void startScanning(CdsEntity attachmentEntityToScan, String contentId) { contentId, attachmentEntityToScan.getQualifiedName()); attachmentMalwareScanner.scanAttachment( - attachmentEntityToScan, contentId); + attachmentEntityToScan, contentId, context); }); }); return null; diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/malware/AsyncMalwareScanExecutor.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/malware/AsyncMalwareScanExecutor.java index fc6413643..b2520340f 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/malware/AsyncMalwareScanExecutor.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/malware/AsyncMalwareScanExecutor.java @@ -3,6 +3,7 @@ */ package com.sap.cds.feature.attachments.service.malware; +import com.sap.cds.feature.attachments.handler.common.AttachmentContext; import com.sap.cds.reflect.CdsEntity; /** Supports asynchronous malware scanning of attachments. */ @@ -13,6 +14,7 @@ public interface AsyncMalwareScanExecutor { * * @param attachmentEntity The entity containing the attachment to scan * @param contentId The content id of the attachment entity + * @param attachmentContext The context describing how to address the attachment's fields */ - void scanAsync(CdsEntity attachmentEntity, String contentId); + void scanAsync(CdsEntity attachmentEntity, String contentId, AttachmentContext attachmentContext); } diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/malware/AttachmentMalwareScanner.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/malware/AttachmentMalwareScanner.java index d64d60cbe..726723f5b 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/malware/AttachmentMalwareScanner.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/malware/AttachmentMalwareScanner.java @@ -3,6 +3,7 @@ */ package com.sap.cds.feature.attachments.service.malware; +import com.sap.cds.feature.attachments.handler.common.AttachmentContext; import com.sap.cds.reflect.CdsEntity; import com.sap.cds.services.ServiceException; @@ -18,7 +19,9 @@ public interface AttachmentMalwareScanner { * * @param attachmentEntity The entity containing the attachment to scan * @param contentId The content id of the attachment entity + * @param attachmentContext The context describing how to address the attachment's fields * @throws ServiceException Exception to be thrown in case of errors during scanning the content */ - void scanAttachment(CdsEntity attachmentEntity, String contentId); + void scanAttachment( + CdsEntity attachmentEntity, String contentId, AttachmentContext attachmentContext); } diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/malware/DefaultAttachmentMalwareScanner.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/malware/DefaultAttachmentMalwareScanner.java index 8f22ad570..6062275f7 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/malware/DefaultAttachmentMalwareScanner.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/malware/DefaultAttachmentMalwareScanner.java @@ -9,6 +9,7 @@ import com.sap.cds.Result; import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments; import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.StatusCode; +import com.sap.cds.feature.attachments.handler.common.AttachmentContext; import com.sap.cds.feature.attachments.service.AttachmentService; import com.sap.cds.feature.attachments.service.malware.client.MalwareScanClient; import com.sap.cds.feature.attachments.service.malware.client.MalwareScanResultStatus; @@ -23,7 +24,9 @@ import java.io.InputStream; import java.time.Instant; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Objects; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -67,15 +70,18 @@ public DefaultAttachmentMalwareScanner( } @Override - public void scanAttachment(CdsEntity attachmentEntity, String contentId) { + public void scanAttachment( + CdsEntity attachmentEntity, String contentId, AttachmentContext attachmentContext) { logger.debug( "Started scanning attachment {} of entity {}.", contentId, attachmentEntity.getQualifiedName()); - List selectionResults = selectData(attachmentEntity, contentId); + List selectionResults = + selectData(attachmentEntity, contentId, attachmentContext); - MalwareScanResultStatus status = findAndScanAttachments(selectionResults, contentId); + MalwareScanResultStatus status = + findAndScanAttachments(selectionResults, contentId, attachmentContext); if (status == null) { logger.debug("Attachment {} not found in any entity, skipping update.", contentId); @@ -84,17 +90,23 @@ public void scanAttachment(CdsEntity attachmentEntity, String contentId) { // Update ALL candidate entities. This ensures the scan result is persisted // even if the attachment moved between draft and active tables during the scan. - for (SelectionResult result : selectionResults) { - updateData(result.entity, contentId, status); + for (SelectionResult selectionResult : selectionResults) { + Map keys = extractKeys(selectionResult.result(), selectionResult.entity()); + updateData(selectionResult.entity(), contentId, status, attachmentContext, keys); } } private MalwareScanResultStatus findAndScanAttachments( - List selectionResults, String contentId) { + List selectionResults, + String contentId, + AttachmentContext attachmentContext) { return selectionResults.stream() .filter(result -> validateAndFilter(result, contentId)) .findFirst() - .map(result -> scanDocument(result.result().single(Attachments.class), result.entity)) + .map( + result -> + scanDocument( + extractAttachment(result.result(), attachmentContext), result.entity())) .orElse(null); } @@ -104,7 +116,7 @@ private boolean validateAndFilter(SelectionResult result, String contentId) { logger.debug( "No attachments {} found in entity {}, nothing to scan.", contentId, - result.entity.getQualifiedName()); + result.entity().getQualifiedName()); return false; } @@ -112,7 +124,7 @@ private boolean validateAndFilter(SelectionResult result, String contentId) { logger.warn( "More than one attachment {} found in entity {}.", contentId, - result.entity.getQualifiedName()); + result.entity().getQualifiedName()); throw new IllegalStateException( "More than one attachment with contentId %s.".formatted(contentId)); } @@ -120,41 +132,80 @@ private boolean validateAndFilter(SelectionResult result, String contentId) { return true; } - private List selectData(CdsEntity attachmentEntity, String contentId) { + private Attachments extractAttachment(Result queryResult, AttachmentContext attachmentContext) { + if (!attachmentContext.isInline()) { + return queryResult.single(Attachments.class); + } + String prefix = ((AttachmentContext.Inline) attachmentContext).prefix() + "_"; + var row = queryResult.single(); + Attachments attachment = Attachments.create(); + attachment.setContentId((String) row.get(prefix + Attachments.CONTENT_ID)); + attachment.setContent((InputStream) row.get(prefix + Attachments.CONTENT)); + attachment.setStatus((String) row.get(prefix + Attachments.STATUS)); + return attachment; + } + + private List selectData( + CdsEntity attachmentEntity, String contentId, AttachmentContext attachmentContext) { List result = new ArrayList<>(); try { CdsEntity entity = (CdsEntity) attachmentEntity.getTargetOf(Drafts.SIBLING_ENTITY); - Result selectionResult = readData(contentId, entity); + Result selectionResult = readData(contentId, entity, attachmentContext); result.add(new SelectionResult(entity, selectionResult)); } catch (CdsElementNotFoundException ignored) { // no sibling found nothing to select } - Result selectionResult = readData(contentId, attachmentEntity); + Result selectionResult = readData(contentId, attachmentEntity, attachmentContext); result.add(new SelectionResult(attachmentEntity, selectionResult)); return result; } - private Result readData(String contentId, CdsEntity entity) { + private Map extractKeys(Result result, CdsEntity entity) { + if (result.rowCount() != 1) { + return Map.of(); + } + var row = result.single(); + Map keys = new HashMap<>(); + entity + .keyElements() + .forEach( + keyElement -> { + String keyName = keyElement.getName(); + Object value = row.get(keyName); + if (value != null) { + keys.put(keyName, value); + } + }); + return keys; + } + + private Result readData(String contentId, CdsEntity entity, AttachmentContext attachmentContext) { + String contentIdCol = attachmentContext.fieldName(Attachments.CONTENT_ID); + String contentCol = attachmentContext.fieldName(Attachments.CONTENT); + String statusCol = attachmentContext.fieldName(Attachments.STATUS); + + List columns = new ArrayList<>(); + columns.add(contentIdCol); + columns.add(contentCol); + columns.add(statusCol); + entity.keyElements().forEach(keyElement -> columns.add(keyElement.getName())); + CqnSelect select = Select.from(entity) - .columns(Attachments.CONTENT_ID, Attachments.CONTENT, Attachments.STATUS) + .columns(columns.toArray(String[]::new)) .where( - e -> - e.get(Attachments.CONTENT_ID) - .eq(contentId) - .and(e.get(Attachments.STATUS).ne(StatusCode.CLEAN))); + e -> e.get(contentIdCol).eq(contentId).and(e.get(statusCol).ne(StatusCode.CLEAN))); Result result = persistenceService.run(select); - result - .streamOf(Attachments.class) + result.stream() .forEach( - attachment -> + row -> logger.debug( "Found attachment {} in entity {} with status {}.", - attachment.getContentId(), + row.get(contentIdCol), entity.getQualifiedName(), - attachment.getStatus())); + row.get(statusCol))); return result; } @@ -180,20 +231,36 @@ private MalwareScanResultStatus scanDocument(Attachments attachment, CdsEntity a } private void updateData( - CdsEntity attachmentEntity, String contentId, MalwareScanResultStatus status) { + CdsEntity attachmentEntity, + String contentId, + MalwareScanResultStatus status, + AttachmentContext attachmentContext, + Map entityKeys) { + String contentIdCol = attachmentContext.fieldName(Attachments.CONTENT_ID); + String statusCol = attachmentContext.fieldName(Attachments.STATUS); + String scannedAtCol = attachmentContext.fieldName(Attachments.SCANNED_AT); + + String mappedStatus = mapStatus(status); + Instant scannedAt = Instant.now(); + Attachments updateData = Attachments.create(); - updateData.setStatus(mapStatus(status)); - updateData.setScannedAt(Instant.now()); + updateData.put(statusCol, mappedStatus); + updateData.put(scannedAtCol, scannedAt); - CqnUpdate update = - Update.entity(attachmentEntity) - .data(updateData) - .where(entry -> entry.get(Attachments.CONTENT_ID).eq(contentId)); + CqnUpdate update; + if (entityKeys.isEmpty()) { + update = + Update.entity(attachmentEntity) + .data(updateData) + .where(entry -> entry.get(contentIdCol).eq(contentId)); + } else { + update = Update.entity(attachmentEntity).data(updateData).matching(entityKeys); + } Result result = persistenceService.run(update); logger.debug( "Updated scan status to {} of attachment {} in entity {} -> Row count {}.", - updateData.getStatus(), + mappedStatus, contentId, attachmentEntity.getQualifiedName(), result.rowCount()); diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/model/service/CreateAttachmentInput.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/model/service/CreateAttachmentInput.java index df4636b3d..3bdb9fccb 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/model/service/CreateAttachmentInput.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/model/service/CreateAttachmentInput.java @@ -3,6 +3,7 @@ */ package com.sap.cds.feature.attachments.service.model.service; +import com.sap.cds.feature.attachments.handler.common.AttachmentContext; import com.sap.cds.reflect.CdsEntity; import java.io.InputStream; import java.util.Map; @@ -15,10 +16,12 @@ * @param fileName The file name of the content * @param mimeType The mime type of the content * @param content The input stream of the content + * @param attachmentContext The context describing how to address this attachment's fields */ public record CreateAttachmentInput( Map attachmentIds, CdsEntity attachmentEntity, String fileName, String mimeType, - InputStream content) {} + InputStream content, + AttachmentContext attachmentContext) {} diff --git a/cds-feature-attachments/src/main/resources/cds/com.sap.cds/cds-feature-attachments/attachments-annotations.cds b/cds-feature-attachments/src/main/resources/cds/com.sap.cds/cds-feature-attachments/attachments-annotations.cds index 85ed597a2..3525f653c 100644 --- a/cds-feature-attachments/src/main/resources/cds/com.sap.cds/cds-feature-attachments/attachments-annotations.cds +++ b/cds-feature-attachments/src/main/resources/cds/com.sap.cds/cds-feature-attachments/attachments-annotations.cds @@ -1,12 +1,15 @@ using { + sap.attachments.Attachment, sap.attachments.MediaData, sap.attachments.Attachments } from './attachments'; annotate sap.attachments.MediaData with @UI.MediaResource: {Stream: content} { content @( - title : '{i18n>attachment_content}', - Core.MediaType: mimeType, + title : '{i18n>attachment_content}', + Core.ContentDisposition.Type : 'inline', + Core.MediaType : (mimeType), + Core.ContentDisposition.Filename: (fileName), ); mimeType @( title: '{i18n>attachment_mimeType}', @@ -92,4 +95,8 @@ annotate sap.attachments.Attachments with @Common : {SideEffects #ContentChanged: { SourceProperties: [content], TargetProperties: ['status'] -}} +}}; + +annotate sap.attachments.Attachment with { + content @Core.ContentDisposition.Filename: (fileName); +} diff --git a/cds-feature-attachments/src/main/resources/cds/com.sap.cds/cds-feature-attachments/attachments.cds b/cds-feature-attachments/src/main/resources/cds/com.sap.cds/cds-feature-attachments/attachments.cds index 7fba49eec..a1fd61e2d 100644 --- a/cds-feature-attachments/src/main/resources/cds/com.sap.cds/cds-feature-attachments/attachments.cds +++ b/cds-feature-attachments/src/main/resources/cds/com.sap.cds/cds-feature-attachments/attachments.cds @@ -8,6 +8,8 @@ using { // using { Attachments } from 'com.sap.cds/cds-feature-attachments' aspect Attachments : sap.attachments.Attachments {} +type Attachment : sap.attachments.Attachment; + context sap.attachments { type StatusCode : String(32) enum { @@ -24,7 +26,7 @@ context sap.attachments { criticality : Integer @UI.Hidden; } - aspect MediaData @(_is_media_data) : managed { + aspect MediaData @(_is_media_data) { content : LargeBinary; // stored only for db-based services mimeType : String; fileName : String(5000); @@ -34,7 +36,9 @@ context sap.attachments { note : String(5000); } - aspect Attachments : cuid, MediaData { + type Attachment : MediaData {} + + aspect Attachments : cuid, MediaData, managed { statusNav : Association to one ScanStates on statusNav.code = status; } 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 37ed28c8a..746b07ab8 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 @@ -219,7 +219,7 @@ void attachmentAccessExceptionCorrectHandledForCreate() { attachment.setFileName("test.txt"); attachment.setContent(null); when(eventFactory.getEvent(any(), any(), any())).thenReturn(event); - when(event.processEvent(any(), any(), any(), any())).thenThrow(new ServiceException("")); + when(event.processEvent(any(), any(), any(), any(), any())).thenThrow(new ServiceException("")); List input = List.of(attachment); assertThrows(ServiceException.class, () -> cut.processBefore(createContext, input)); diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/DeleteAttachmentsHandlerTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/DeleteAttachmentsHandlerTest.java index f1187fa47..52a889ef7 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/DeleteAttachmentsHandlerTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/DeleteAttachmentsHandlerTest.java @@ -18,6 +18,7 @@ import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.testservice.Attachment; import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.testservice.Attachment_; import com.sap.cds.feature.attachments.handler.applicationservice.modifyevents.MarkAsDeletedAttachmentEvent; +import com.sap.cds.feature.attachments.handler.common.AttachmentContext; import com.sap.cds.feature.attachments.handler.common.AttachmentsReader; import com.sap.cds.feature.attachments.handler.helper.RuntimeHelper; import com.sap.cds.ql.cqn.Path; @@ -80,7 +81,8 @@ void attachmentDataExistsServiceIsCalled() { cut.processBefore(context); - verify(modifyAttachmentEvent).processEvent(any(), eq(inputStream), eq(data), eq(context)); + verify(modifyAttachmentEvent) + .processEvent(any(), eq(inputStream), eq(data), eq(context), any(AttachmentContext.class)); assertThat(data.getContent()).isNull(); } @@ -104,10 +106,18 @@ void attachmentDataExistsAsExpandServiceIsCalled() { verify(modifyAttachmentEvent) .processEvent( - any(Path.class), eq(inputStream), eq(Attachments.of(attachment1)), eq(context)); + any(Path.class), + eq(inputStream), + eq(Attachments.of(attachment1)), + eq(context), + any(AttachmentContext.class)); verify(modifyAttachmentEvent) .processEvent( - any(Path.class), eq(inputStream), eq(Attachments.of(attachment2)), eq(context)); + any(Path.class), + eq(inputStream), + eq(Attachments.of(attachment2)), + eq(context), + any(AttachmentContext.class)); assertThat(attachment1.getContent()).isNull(); assertThat(attachment2.getContent()).isNull(); } diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/ReadAttachmentsHandlerTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/ReadAttachmentsHandlerTest.java index 524c52c85..a2cd58f77 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/ReadAttachmentsHandlerTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/ReadAttachmentsHandlerTest.java @@ -19,6 +19,7 @@ import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.EventItems; import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.EventItems_; import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.testservice.Attachment_; +import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.testservice.InlineOnly_; import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.testservice.Items; import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.testservice.RootTable; import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.testservice.RootTable_; @@ -26,6 +27,7 @@ import com.sap.cds.feature.attachments.handler.applicationservice.readhelper.AttachmentStatusValidator; import com.sap.cds.feature.attachments.handler.applicationservice.readhelper.LazyProxyInputStream; import com.sap.cds.feature.attachments.handler.common.AssociationCascader; +import com.sap.cds.feature.attachments.handler.common.AttachmentContext; import com.sap.cds.feature.attachments.handler.helper.RuntimeHelper; import com.sap.cds.feature.attachments.service.AttachmentService; import com.sap.cds.feature.attachments.service.malware.AsyncMalwareScanExecutor; @@ -230,7 +232,10 @@ void scannerCalledForUnscannedAttachments() { cut.processAfter(readEventContext, List.of(attachment)); verify(asyncMalwareScanExecutor) - .scanAsync(readEventContext.getTarget(), attachment.getContentId()); + .scanAsync( + readEventContext.getTarget(), + attachment.getContentId(), + new AttachmentContext.Composition()); } @Test @@ -244,7 +249,10 @@ void scannerCalledForUnscannedAttachmentsIfNoContentProvided() { cut.processAfter(readEventContext, List.of(attachment)); verify(asyncMalwareScanExecutor) - .scanAsync(readEventContext.getTarget(), attachment.getContentId()); + .scanAsync( + readEventContext.getTarget(), + attachment.getContentId(), + new AttachmentContext.Composition()); } @Test @@ -278,7 +286,10 @@ void scannerCalledForStaleCleanAttachment() { verify(persistenceService).run(any(com.sap.cds.ql.cqn.CqnUpdate.class)); verify(asyncMalwareScanExecutor) - .scanAsync(readEventContext.getTarget(), attachment.getContentId()); + .scanAsync( + readEventContext.getTarget(), + attachment.getContentId(), + new AttachmentContext.Composition()); assertThat(attachment.getStatus()).isEqualTo(StatusCode.SCANNING); } @@ -300,7 +311,10 @@ void scannerCalledForCleanAttachmentWithNullScannedAt() { verify(persistenceService).run(any(com.sap.cds.ql.cqn.CqnUpdate.class)); verify(asyncMalwareScanExecutor) - .scanAsync(readEventContext.getTarget(), attachment.getContentId()); + .scanAsync( + readEventContext.getTarget(), + attachment.getContentId(), + new AttachmentContext.Composition()); assertThat(attachment.getStatus()).isEqualTo(StatusCode.SCANNING); } @@ -347,7 +361,10 @@ void persistenceServiceNotCalledForUnscannedAttachments() { cut.processAfter(readEventContext, List.of(attachment)); verify(asyncMalwareScanExecutor) - .scanAsync(readEventContext.getTarget(), attachment.getContentId()); + .scanAsync( + readEventContext.getTarget(), + attachment.getContentId(), + new AttachmentContext.Composition()); verify(attachmentStatusValidator).verifyStatus(StatusCode.UNSCANNED); verifyNoInteractions(persistenceService); } @@ -466,6 +483,48 @@ void scannerNotAvailable_unscannedAttachmentStillFailsValidation() { verifyNoInteractions(persistenceService); } + @Test + void processBeforeWithInlineOnlyEntity() { + var select = Select.from(InlineOnly_.class).columns(InlineOnly_::ID); + mockEventContext(InlineOnly_.CDS_NAME, select); + + cut.processBefore(readEventContext); + } + + @Test + void processBeforeWithBothCompositionAndInlineEntity() { + var select = Select.from(RootTable_.class).columns(RootTable_::ID); + mockEventContext(RootTable_.CDS_NAME, select); + + cut.processBefore(readEventContext); + } + + @Test + void processAfterWithInlineAttachmentData() { + mockEventContext(RootTable_.CDS_NAME, mock(CqnSelect.class)); + var root = RootTable.create(); + root.setProfilePictureContentId("inline-cid"); + root.setProfilePictureContent(mock(InputStream.class)); + root.setProfilePictureStatus(StatusCode.CLEAN); + root.setProfilePictureScannedAt(Instant.now()); + + cut.processAfter(readEventContext, List.of(root)); + + assertThat(root.getProfilePictureContent()).isInstanceOf(LazyProxyInputStream.class); + } + + @Test + void processAfterWithInlineAttachmentWithoutContentIdReturnsOriginalValue() { + mockEventContext(RootTable_.CDS_NAME, mock(CqnSelect.class)); + var root = RootTable.create(); + var originalStream = mock(InputStream.class); + root.setProfilePictureContent(originalStream); + + cut.processAfter(readEventContext, List.of(root)); + + assertThat(root.getProfilePictureContent()).isSameAs(originalStream); + } + private void mockEventContext(String entityName, CqnSelect select) { var serviceEntity = runtime.getCdsModel().findEntity(entityName); when(readEventContext.getTarget()).thenReturn(serviceEntity.orElseThrow()); 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 8ea36c180..d1f32eb46 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 @@ -247,7 +247,7 @@ void attachmentAccessExceptionCorrectHandledForUpdate() { attachment.setFileName("test.txt"); attachment.setContent(null); attachment.setId(id); - when(event.processEvent(any(), any(), any(), any())).thenThrow(new ServiceException("")); + when(event.processEvent(any(), any(), any(), any(), any())).thenThrow(new ServiceException("")); when(attachmentsReader.readAttachments(any(), any(), any(CqnFilterableStatement.class))) .thenReturn(List.of(attachment)); @@ -284,7 +284,11 @@ void existingDataFoundAndUsed() { ArgumentCaptor eventStreamCaptor = ArgumentCaptor.forClass(InputStream.class); verify(event) .processEvent( - any(), eventStreamCaptor.capture(), cdsDataArgumentCaptor.capture(), eq(updateContext)); + any(), + eventStreamCaptor.capture(), + cdsDataArgumentCaptor.capture(), + eq(updateContext), + any()); InputStream eventCaptured = eventStreamCaptor.getValue(); assertThat(eventCaptured).isInstanceOf(CountingInputStream.class); assertThat(((CountingInputStream) eventCaptured).getDelegate()).isSameAs(testStream); diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ModifyApplicationHandlerHelperTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ModifyApplicationHandlerHelperTest.java index 489017824..5d7be8a96 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ModifyApplicationHandlerHelperTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ModifyApplicationHandlerHelperTest.java @@ -13,6 +13,7 @@ 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.common.AttachmentContext; import com.sap.cds.feature.attachments.handler.helper.RuntimeHelper; import com.sap.cds.ql.cqn.Path; import com.sap.cds.ql.cqn.ResolvedSegment; @@ -92,7 +93,8 @@ void serviceExceptionDueToContentLength() { eventContext, path, attachment.getContent(), - ModifyApplicationHandlerHelper.DEFAULT_SIZE_WITH_SCANNER)); + ModifyApplicationHandlerHelper.DEFAULT_SIZE_WITH_SCANNER, + new AttachmentContext.Composition())); assertThat(exception.getErrorStatus()).isEqualTo(ExtendedErrorStatuses.CONTENT_TOO_LARGE); } @@ -119,7 +121,7 @@ void serviceExceptionDueToLimitExceeded() { when(parameterInfo.getHeader("Content-Length")).thenReturn(null); // Make event.processEvent() read from the stream, triggering the limit check - when(event.processEvent(any(), any(), any(), any())) + when(event.processEvent(any(), any(), any(), any(), any())) .thenAnswer( invocation -> { InputStream wrappedContent = invocation.getArgument(1); @@ -146,7 +148,8 @@ void serviceExceptionDueToLimitExceeded() { eventContext, path, content, - ModifyApplicationHandlerHelper.DEFAULT_SIZE_WITH_SCANNER)); + ModifyApplicationHandlerHelper.DEFAULT_SIZE_WITH_SCANNER, + new AttachmentContext.Composition())); assertThat(exception.getErrorStatus()).isEqualTo(ExtendedErrorStatuses.CONTENT_TOO_LARGE); } @@ -178,7 +181,8 @@ void defaultValMaxValueUsed() { eventContext, path, content, - ModifyApplicationHandlerHelper.DEFAULT_SIZE_WITH_SCANNER)); + ModifyApplicationHandlerHelper.DEFAULT_SIZE_WITH_SCANNER, + new AttachmentContext.Composition())); } @Test @@ -210,8 +214,120 @@ void malformedContentLengthHeader() { eventContext, path, content, - ModifyApplicationHandlerHelper.DEFAULT_SIZE_WITH_SCANNER)); + ModifyApplicationHandlerHelper.DEFAULT_SIZE_WITH_SCANNER, + new AttachmentContext.Composition())); assertThat(exception.getErrorStatus()).isEqualTo(ErrorStatuses.BAD_REQUEST); } + + @Test + void inlineAttachmentSizeLimitFromAnnotation() { + String inlineOnlyEntityName = "unit.test.TestService.InlineOnly"; + CdsEntity entity = runtime.getCdsModel().findEntity(inlineOnlyEntityName).orElseThrow(); + + var data = Attachments.create(); + data.setId(UUID.randomUUID().toString()); + data.put("avatar_contentId", "inline-cid"); + data.put("avatar_content", mock(InputStream.class)); + + when(target.entity()).thenReturn(entity); + when(target.values()).thenReturn(data); + when(target.keys()).thenReturn(Map.of("ID", data.getId())); + + when(parameterInfo.getHeader("Content-Length")).thenReturn("20000"); + + var existingAttachments = List.of(); + + var exception = + assertThrows( + ServiceException.class, + () -> + ModifyApplicationHandlerHelper.handleAttachmentForEntity( + existingAttachments, + eventFactory, + eventContext, + path, + (InputStream) data.get("avatar_content"), + ModifyApplicationHandlerHelper.DEFAULT_SIZE_WITH_SCANNER, + new AttachmentContext.Inline("avatar"))); + + assertThat(exception.getErrorStatus()).isEqualTo(ExtendedErrorStatuses.CONTENT_TOO_LARGE); + } + + @Test + void inlineAttachmentWithinSizeLimitSucceeds() { + String inlineOnlyEntityName = "unit.test.TestService.InlineOnly"; + CdsEntity entity = runtime.getCdsModel().findEntity(inlineOnlyEntityName).orElseThrow(); + + var data = Attachments.create(); + data.setId(UUID.randomUUID().toString()); + data.put("avatar_contentId", "inline-cid"); + var content = mock(InputStream.class); + data.put("avatar_content", content); + + when(target.entity()).thenReturn(entity); + when(target.values()).thenReturn(data); + when(target.keys()).thenReturn(Map.of("ID", data.getId())); + + when(parameterInfo.getHeader("Content-Length")).thenReturn("5000"); + + var existingAttachments = List.of(); + + assertDoesNotThrow( + () -> + ModifyApplicationHandlerHelper.handleAttachmentForEntity( + existingAttachments, + eventFactory, + eventContext, + path, + content, + ModifyApplicationHandlerHelper.DEFAULT_SIZE_WITH_SCANNER, + new AttachmentContext.Inline("avatar"))); + } + + @Test + void streamingLimitExceededOnInlineAttachment() { + String inlineOnlyEntityName = "unit.test.TestService.InlineOnly"; + CdsEntity entity = runtime.getCdsModel().findEntity(inlineOnlyEntityName).orElseThrow(); + + var data = Attachments.create(); + data.setId(UUID.randomUUID().toString()); + data.put("avatar_contentId", "inline-cid"); + byte[] largeContent = new byte[15000]; // 15KB > 10KB limit + var content = new ByteArrayInputStream(largeContent); + data.put("avatar_content", content); + + when(target.entity()).thenReturn(entity); + when(target.values()).thenReturn(data); + when(target.keys()).thenReturn(Map.of("ID", data.getId())); + when(parameterInfo.getHeader("Content-Length")).thenReturn(null); + + when(event.processEvent(any(), any(), any(), any(), any())) + .thenAnswer( + invocation -> { + InputStream wrappedContent = invocation.getArgument(1); + if (wrappedContent != null) { + byte[] buffer = new byte[1024]; + while (wrappedContent.read(buffer) != -1) {} + } + return null; + }); + + var existingAttachments = List.of(); + + var exception = + assertThrows( + ServiceException.class, + () -> + ModifyApplicationHandlerHelper.handleAttachmentForEntity( + existingAttachments, + eventFactory, + eventContext, + path, + content, + ModifyApplicationHandlerHelper.DEFAULT_SIZE_WITH_SCANNER, + new AttachmentContext.Inline("avatar"))); + + assertThat(exception.getErrorStatus()).isEqualTo(ExtendedErrorStatuses.CONTENT_TOO_LARGE); + } } diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ReadonlyDataContextEnhancerTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ReadonlyDataContextEnhancerTest.java index eb20bd79b..62d7fff6d 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ReadonlyDataContextEnhancerTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ReadonlyDataContextEnhancerTest.java @@ -7,11 +7,15 @@ import com.sap.cds.CdsData; import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments; +import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.MediaData; import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.Events_; import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.testservice.Attachment_; +import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.testservice.RootTable_; +import com.sap.cds.feature.attachments.handler.common.AttachmentContext; import com.sap.cds.feature.attachments.handler.helper.RuntimeHelper; import com.sap.cds.reflect.CdsEntity; import com.sap.cds.services.runtime.CdsRuntime; +import java.io.ByteArrayInputStream; import java.time.Instant; import java.util.List; import org.junit.jupiter.api.BeforeAll; @@ -28,88 +32,92 @@ static void classSetup() { runtime = RuntimeHelper.runtime; } - @Test - void preserveReadonlyFields_isDraft_backupCreated() { - CdsEntity entity = runtime.getCdsModel().findEntity(Attachment_.CDS_NAME).orElseThrow(); - - var attachment = Attachments.create(); - attachment.setContentId("doc-123"); - attachment.setStatus("Clean"); - Instant scannedAt = Instant.parse("2024-06-01T12:00:00Z"); - attachment.setScannedAt(scannedAt); - attachment.setContent(null); - - ReadonlyDataContextEnhancer.preserveReadonlyFields(entity, List.of(attachment), true); + private CdsEntity getRootTableEntity() { + return runtime.getCdsModel().findEntity(RootTable_.CDS_NAME).orElseThrow(); + } - assertThat(attachment.get(DRAFT_READONLY_CONTEXT)).isNotNull(); - var backup = (CdsData) attachment.get(DRAFT_READONLY_CONTEXT); - assertThat(backup) - .containsEntry(Attachments.CONTENT_ID, "doc-123") - .containsEntry(Attachments.STATUS, "Clean") - .containsEntry(Attachments.SCANNED_AT, scannedAt) - .doesNotContainKey(Attachments.CONTENT); + private CdsEntity getAttachmentEntity() { + return runtime + .getCdsModel() + .findEntity("unit.test.TestService.RootTable.attachments") + .orElseThrow(); } - @Test - void preserveReadonlyFields_isNotDraft_backupRemoved() { - CdsEntity entity = runtime.getCdsModel().findEntity(Attachment_.CDS_NAME).orElseThrow(); + // --- Composition-based preserve/restore tests --- - var attachment = Attachments.create(); - attachment.setContentId("doc-456"); - attachment.setContent(null); - var existingBackup = CdsData.create(); - existingBackup.put(Attachments.CONTENT_ID, "old-id"); - existingBackup.put(Attachments.STATUS, "old-status"); - existingBackup.put(Attachments.SCANNED_AT, Instant.EPOCH); - attachment.put(DRAFT_READONLY_CONTEXT, existingBackup); + @Test + void preserveReadonlyFieldsForDraftComposition() { + CdsEntity entity = getAttachmentEntity(); + CdsData data = CdsData.create(); + data.put(Attachments.CONTENT, new ByteArrayInputStream(new byte[0])); + data.put(Attachments.CONTENT_ID, "cid-123"); + data.put(Attachments.STATUS, "Clean"); + Instant now = Instant.now(); + data.put(Attachments.SCANNED_AT, now); - ReadonlyDataContextEnhancer.preserveReadonlyFields(entity, List.of(attachment), false); + ReadonlyDataContextEnhancer.preserveReadonlyFields(entity, List.of(data), true); - assertThat(attachment.get(DRAFT_READONLY_CONTEXT)).isNull(); + CdsData backup = (CdsData) data.get(DRAFT_READONLY_CONTEXT); + assertThat(backup).isNotNull(); + assertThat(backup.get(Attachments.CONTENT_ID)).isEqualTo("cid-123"); + assertThat(backup.get(Attachments.STATUS)).isEqualTo("Clean"); + assertThat(backup.get(Attachments.SCANNED_AT)).isEqualTo(now); } @Test - void preserveReadonlyFields_isDraft_noAttachmentEntity_nothingHappens() { - CdsEntity entity = runtime.getCdsModel().findEntity(Events_.CDS_NAME).orElseThrow(); + void preserveReadonlyFieldsNonDraftRemovesContext() { + CdsEntity entity = getAttachmentEntity(); + CdsData data = CdsData.create(); + data.put(Attachments.CONTENT, new ByteArrayInputStream(new byte[0])); + data.put(Attachments.CONTENT_ID, "cid-123"); + data.put(DRAFT_READONLY_CONTEXT, Attachments.create()); - var data = CdsData.create(); - data.put("content", "some text"); - - ReadonlyDataContextEnhancer.preserveReadonlyFields(entity, List.of(data), true); + ReadonlyDataContextEnhancer.preserveReadonlyFields(entity, List.of(data), false); - assertThat(data.get(DRAFT_READONLY_CONTEXT)).isNull(); + assertThat(data.containsKey(DRAFT_READONLY_CONTEXT)).isFalse(); } @Test - void restoreReadonlyFields_withBackup_fieldsRestoredAndBackupRemoved() { - var data = CdsData.create(); - var backup = CdsData.create(); - backup.put(Attachments.CONTENT_ID, "restored-id"); - backup.put(Attachments.STATUS, "Infected"); - Instant scannedAt = Instant.parse("2025-01-15T08:30:00Z"); - backup.put(Attachments.SCANNED_AT, scannedAt); + void restoreReadonlyFieldsComposition() { + CdsData data = CdsData.create(); + Attachments backup = Attachments.create(); + backup.setContentId("cid-restored"); + backup.setStatus("Scanning"); + Instant scannedAt = Instant.now(); + backup.setScannedAt(scannedAt); data.put(DRAFT_READONLY_CONTEXT, backup); - ReadonlyDataContextEnhancer.restoreReadonlyFields(data); + ReadonlyDataContextEnhancer.restoreReadonlyFields(data, new AttachmentContext.Composition()); - assertThat(data.get(Attachments.CONTENT_ID)).isEqualTo("restored-id"); - assertThat(data.get(Attachments.STATUS)).isEqualTo("Infected"); + assertThat(data.get(Attachments.CONTENT_ID)).isEqualTo("cid-restored"); + assertThat(data.get(Attachments.STATUS)).isEqualTo("Scanning"); assertThat(data.get(Attachments.SCANNED_AT)).isEqualTo(scannedAt); - assertThat(data.get(DRAFT_READONLY_CONTEXT)).isNull(); + assertThat(data.containsKey(DRAFT_READONLY_CONTEXT)).isFalse(); + } + + @Test + void restoreReadonlyFieldsNoBackupDoesNothing() { + CdsData data = CdsData.create(); + data.put("ID", "123"); + + ReadonlyDataContextEnhancer.restoreReadonlyFields(data, new AttachmentContext.Composition()); + + assertThat(data.get("ID")).isEqualTo("123"); + assertThat(data).hasSize(1); } + // --- Edge-case tests from main --- + @Test - void restoreReadonlyFields_withoutBackup_noOp() { + void preserveReadonlyFields_isDraft_noAttachmentEntity_nothingHappens() { + CdsEntity entity = runtime.getCdsModel().findEntity(Events_.CDS_NAME).orElseThrow(); + var data = CdsData.create(); - data.put("someKey", "someValue"); + data.put("content", "some text"); - ReadonlyDataContextEnhancer.restoreReadonlyFields(data); + ReadonlyDataContextEnhancer.preserveReadonlyFields(entity, List.of(data), true); - assertThat(data).containsEntry("someKey", "someValue"); - assertThat(data).doesNotContainKey(DRAFT_READONLY_CONTEXT); - assertThat(data).doesNotContainKey(Attachments.CONTENT_ID); - assertThat(data).doesNotContainKey(Attachments.STATUS); - assertThat(data).doesNotContainKey(Attachments.SCANNED_AT); + assertThat(data.get(DRAFT_READONLY_CONTEXT)).isNull(); } @Test @@ -121,7 +129,7 @@ void restoreReadonlyFields_withPartialBackup_nullsOverwriteExistingValues() { // STATUS and SCANNED_AT intentionally absent from backup data.put(DRAFT_READONLY_CONTEXT, backup); - ReadonlyDataContextEnhancer.restoreReadonlyFields(data); + ReadonlyDataContextEnhancer.restoreReadonlyFields(data, new AttachmentContext.Composition()); assertThat(data.get(Attachments.CONTENT_ID)).isEqualTo("restored-id"); assertThat(data.get(Attachments.STATUS)).isNull(); @@ -139,4 +147,75 @@ void preserveReadonlyFields_isNotDraft_noExistingBackup_nothingHappens() { assertThat(attachment.get(DRAFT_READONLY_CONTEXT)).isNull(); } + + // --- Inline attachment preserve/restore tests --- + + @Test + void preserveReadonlyFieldsForDraftInlineAttachment() { + CdsEntity entity = runtime.getCdsModel().findEntity(RootTable_.CDS_NAME).orElseThrow(); + CdsData data = CdsData.create(); + data.put("profilePicture_content", new ByteArrayInputStream(new byte[0])); + data.put("profilePicture_contentId", "inline-cid"); + data.put("profilePicture_status", "Clean"); + Instant now = Instant.now(); + data.put("profilePicture_scannedAt", now); + data.put("profilePicture_fileName", "photo.jpg"); + + ReadonlyDataContextEnhancer.preserveReadonlyFields(entity, List.of(data), true); + + CdsData backup = (CdsData) data.get("profilePicture_" + DRAFT_READONLY_CONTEXT); + assertThat(backup).isNotNull(); + assertThat(backup.get(Attachments.CONTENT_ID)).isEqualTo("inline-cid"); + assertThat(backup.get(Attachments.STATUS)).isEqualTo("Clean"); + assertThat(backup.get(Attachments.SCANNED_AT)).isEqualTo(now); + assertThat(backup.get(MediaData.FILE_NAME)).isEqualTo("photo.jpg"); + } + + @Test + void restoreReadonlyFieldsForInlineAttachment() { + CdsData data = CdsData.create(); + Attachments backup = Attachments.create(); + backup.setContentId("inline-restored-cid"); + backup.setStatus("Scanning"); + Instant scannedAt = Instant.now(); + backup.setScannedAt(scannedAt); + data.put("profilePicture_" + DRAFT_READONLY_CONTEXT, backup); + + ReadonlyDataContextEnhancer.restoreReadonlyFields( + data, new AttachmentContext.Inline("profilePicture")); + + assertThat(data.get("profilePicture_contentId")).isEqualTo("inline-restored-cid"); + assertThat(data.get("profilePicture_status")).isEqualTo("Scanning"); + assertThat(data.get("profilePicture_scannedAt")).isEqualTo(scannedAt); + assertThat(data.containsKey("profilePicture_" + DRAFT_READONLY_CONTEXT)).isFalse(); + } + + @Test + void restoreReadonlyFieldsForInlineAttachmentWithFileName() { + CdsData data = CdsData.create(); + Attachments backup = Attachments.create(); + backup.setContentId("inline-cid"); + backup.setStatus("Clean"); + backup.setFileName("preserved-file.pdf"); + data.put("profilePicture_" + DRAFT_READONLY_CONTEXT, backup); + + ReadonlyDataContextEnhancer.restoreReadonlyFields( + data, new AttachmentContext.Inline("profilePicture")); + + assertThat(data.get("profilePicture_contentId")).isEqualTo("inline-cid"); + assertThat(data.get("profilePicture_fileName")).isEqualTo("preserved-file.pdf"); + assertThat(data.containsKey("profilePicture_" + DRAFT_READONLY_CONTEXT)).isFalse(); + } + + @Test + void preserveReadonlyFieldsNonDraftRemovesInlinePrefixedContext() { + CdsEntity entity = runtime.getCdsModel().findEntity(RootTable_.CDS_NAME).orElseThrow(); + CdsData data = CdsData.create(); + data.put("profilePicture_content", new ByteArrayInputStream(new byte[0])); + data.put("profilePicture_" + DRAFT_READONLY_CONTEXT, Attachments.create()); + + ReadonlyDataContextEnhancer.preserveReadonlyFields(entity, List.of(data), false); + + assertThat(data.containsKey("profilePicture_" + DRAFT_READONLY_CONTEXT)).isFalse(); + } } diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/AttachmentValidationHelperTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/AttachmentValidationHelperTest.java index 99e05b6cf..49e40ccd9 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/AttachmentValidationHelperTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/AttachmentValidationHelperTest.java @@ -51,7 +51,7 @@ void doesNothing_whenEntityNotFoundInModel() { try (MockedStatic helper = mockStatic(ApplicationHandlerHelper.class)) { - helper.when(() -> ApplicationHandlerHelper.isMediaEntity(entity)).thenReturn(false); + helper.when(() -> ApplicationHandlerHelper.isDirectMediaEntity(entity)).thenReturn(false); setupMockCascader(entity, model, false); @@ -71,7 +71,7 @@ void doesNothing_whenNoEntityHasAcceptableMediaTypesAnnotation() { MockedStatic extractor = mockStatic(AttachmentDataExtractor.class)) { - helper.when(() -> ApplicationHandlerHelper.isMediaEntity(entity)).thenReturn(true); + helper.when(() -> ApplicationHandlerHelper.isDirectMediaEntity(entity)).thenReturn(true); // MediaTypeResolver returns empty map = no entity has the annotation resolver @@ -101,7 +101,7 @@ void doesNotThrow_whenNoFiles() { MockedStatic extractor = mockStatic(AttachmentDataExtractor.class)) { CdsRuntime runtime = mockRuntime(entity); - helper.when(() -> ApplicationHandlerHelper.isMediaEntity(entity)).thenReturn(true); + helper.when(() -> ApplicationHandlerHelper.isDirectMediaEntity(entity)).thenReturn(true); resolver .when( @@ -122,7 +122,7 @@ void doesNotThrow_whenNoFiles() { @ParameterizedTest @MethodSource("validFileScenarios") - void doesNotThrow_whenFilesAreValid(boolean isMediaEntity) { + void doesNotThrow_whenFilesAreValid(boolean isDirectMediaEntity) { CdsEntity entity = mockEntity("Entity"); CdsRuntime runtime = mockRuntime(entity); @@ -136,8 +136,10 @@ void doesNotThrow_whenFilesAreValid(boolean isMediaEntity) { MockedStatic extractor = mockStatic(AttachmentDataExtractor.class)) { - helper.when(() -> ApplicationHandlerHelper.isMediaEntity(entity)).thenReturn(isMediaEntity); - setupMockCascader(entity, runtime.getCdsModel(), !isMediaEntity); + helper + .when(() -> ApplicationHandlerHelper.isDirectMediaEntity(entity)) + .thenReturn(isDirectMediaEntity); + setupMockCascader(entity, runtime.getCdsModel(), !isDirectMediaEntity); resolver .when( @@ -165,7 +167,7 @@ private static Stream validFileScenarios() { @ParameterizedTest @MethodSource("invalidFileScenarios") - void throwsException_whenFilesAreInvalid(boolean isMediaEntity) { + void throwsException_whenFilesAreInvalid(boolean isDirectMediaEntity) { CdsEntity entity = mockEntity("Entity"); CdsRuntime runtime = mockRuntime(entity); @@ -179,8 +181,10 @@ void throwsException_whenFilesAreInvalid(boolean isMediaEntity) { MockedStatic extractor = mockStatic(AttachmentDataExtractor.class)) { - helper.when(() -> ApplicationHandlerHelper.isMediaEntity(entity)).thenReturn(isMediaEntity); - setupMockCascader(entity, runtime.getCdsModel(), !isMediaEntity); + helper + .when(() -> ApplicationHandlerHelper.isDirectMediaEntity(entity)) + .thenReturn(isDirectMediaEntity); + setupMockCascader(entity, runtime.getCdsModel(), !isDirectMediaEntity); resolver .when( diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/MediaTypeResolverTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/MediaTypeResolverTest.java index f0056d06c..eb7bd1f99 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/MediaTypeResolverTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/MediaTypeResolverTest.java @@ -36,7 +36,7 @@ void shouldReturnMediaTypesFromAnnotation() { when(model.getEntity("MediaEntity")).thenReturn(media); - when(media.getElement("content")).thenReturn(element); + when(media.findElement("content")).thenReturn(Optional.of(element)); when(element.findAnnotation("Core.AcceptableMediaTypes")).thenReturn(Optional.of(annotation)); when(annotation.getValue()).thenReturn(List.of("image/png", "image/jpeg")); @@ -52,7 +52,7 @@ void shouldExcludeEntityWithoutAnnotation() { CdsEntity media = mock(CdsEntity.class); when(model.getEntity("MediaEntity")).thenReturn(media); - when(media.getElement(any())).thenReturn(null); + when(media.findElement(any())).thenReturn(Optional.empty()); Map> result = MediaTypeResolver.getAcceptableMediaTypesFromEntity(model, List.of("MediaEntity")); diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/CreateAttachmentEventTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/CreateAttachmentEventTest.java index 8a4e3a99a..cb030a670 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/CreateAttachmentEventTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/CreateAttachmentEventTest.java @@ -6,12 +6,14 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments; import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.MediaData; import com.sap.cds.feature.attachments.handler.applicationservice.transaction.ListenerProvider; +import com.sap.cds.feature.attachments.handler.common.AttachmentContext; import com.sap.cds.feature.attachments.service.AttachmentService; import com.sap.cds.feature.attachments.service.model.service.AttachmentModificationResult; import com.sap.cds.feature.attachments.service.model.service.CreateAttachmentInput; @@ -27,6 +29,7 @@ import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; +import java.util.HashMap; import java.util.Map; import java.util.UUID; import org.junit.jupiter.api.BeforeEach; @@ -101,7 +104,12 @@ void storageCalledWithAllFieldsFilledFromExistingData() { existingData.setFileName("some file name"); existingData.setMimeType("some mime type"); - cut.processEvent(path, attachment.getContent(), existingData, eventContext); + cut.processEvent( + path, + attachment.getContent(), + existingData, + eventContext, + new AttachmentContext.Composition()); verify(attachmentService).createAttachment(contextArgumentCaptor.capture()); var createInput = contextArgumentCaptor.getValue(); @@ -124,7 +132,12 @@ void resultFromServiceStoredInPath() { when(attachmentService.createAttachment(any())).thenReturn(attachmentServiceResult); when(target.values()).thenReturn(attachment); - cut.processEvent(path, attachment.getContent(), Attachments.create(), eventContext); + cut.processEvent( + path, + attachment.getContent(), + Attachments.create(), + eventContext, + new AttachmentContext.Composition()); assertThat(attachment.getContentId()).isEqualTo(attachmentServiceResult.contentId()); assertThat(attachment.getStatus()).isEqualTo(attachmentServiceResult.status()); @@ -140,7 +153,8 @@ void changesetIstRegistered() { when(attachmentService.createAttachment(any())) .thenReturn(new AttachmentModificationResult(false, contentId, "test", null)); - cut.processEvent(path, null, Attachments.create(), eventContext); + cut.processEvent( + path, null, Attachments.create(), eventContext, new AttachmentContext.Composition()); verify(changeSetContext).register(listener); } @@ -161,7 +175,12 @@ void contentIsReturnedIfNotExternalStored(boolean isExternalStored) throws IOExc .thenReturn(new AttachmentModificationResult(isExternalStored, "id", "test", null)); var result = - cut.processEvent(path, attachment.getContent(), Attachments.create(), eventContext); + cut.processEvent( + path, + attachment.getContent(), + Attachments.create(), + eventContext, + new AttachmentContext.Composition()); var expectedContent = isExternalStored ? attachment.getContent() : null; assertThat(result).isEqualTo(expectedContent); @@ -180,7 +199,12 @@ private Attachments prepareAndExecuteEventWithData() { when(attachmentService.createAttachment(any())) .thenReturn(new AttachmentModificationResult(false, "id", "test", null)); - cut.processEvent(path, attachment.getContent(), Attachments.create(), eventContext); + cut.processEvent( + path, + attachment.getContent(), + Attachments.create(), + eventContext, + new AttachmentContext.Composition()); return attachment; } @@ -195,7 +219,8 @@ void fileNameFromRfc5987Header() { when(parameterInfo.getHeader("Content-Disposition")) .thenReturn("attachment; filename*=UTF-8''my%20file%20name.pdf"); - cut.processEvent(path, null, Attachments.create(), eventContext); + cut.processEvent( + path, null, Attachments.create(), eventContext, new AttachmentContext.Composition()); verify(attachmentService).createAttachment(contextArgumentCaptor.capture()); assertThat(contextArgumentCaptor.getValue().fileName()).isEqualTo("my file name.pdf"); @@ -210,11 +235,11 @@ void fileNameFromRfc5987HeaderWithTrailingParams() { when(target.keys()).thenReturn(Map.of("ID", attachment.getId())); when(attachmentService.createAttachment(any())) .thenReturn(new AttachmentModificationResult(false, "id", "test", null)); - // Header with trailing parameters after the filename - should stop at semicolon when(parameterInfo.getHeader("Content-Disposition")) .thenReturn("attachment; filename*=UTF-8''my%20file.pdf; size=1234"); - cut.processEvent(path, null, Attachments.create(), eventContext); + cut.processEvent( + path, null, Attachments.create(), eventContext, new AttachmentContext.Composition()); verify(attachmentService).createAttachment(contextArgumentCaptor.capture()); assertThat(contextArgumentCaptor.getValue().fileName()).isEqualTo("my file.pdf"); @@ -232,7 +257,8 @@ void fileNameFromPlainHeader() { when(parameterInfo.getHeader("Content-Disposition")) .thenReturn("attachment; filename=\"report.pdf\""); - cut.processEvent(path, null, Attachments.create(), eventContext); + cut.processEvent( + path, null, Attachments.create(), eventContext, new AttachmentContext.Composition()); verify(attachmentService).createAttachment(contextArgumentCaptor.capture()); assertThat(contextArgumentCaptor.getValue().fileName()).isEqualTo("report.pdf"); @@ -249,7 +275,8 @@ void fileNameFromPlainHeaderWithoutQuotes() { when(parameterInfo.getHeader("Content-Disposition")) .thenReturn("attachment; filename=report.pdf"); - cut.processEvent(path, null, Attachments.create(), eventContext); + cut.processEvent( + path, null, Attachments.create(), eventContext, new AttachmentContext.Composition()); verify(attachmentService).createAttachment(contextArgumentCaptor.capture()); assertThat(contextArgumentCaptor.getValue().fileName()).isEqualTo("report.pdf"); @@ -266,7 +293,8 @@ void fileNameFromSlugHeader() { when(parameterInfo.getHeader("Content-Disposition")).thenReturn(null); when(parameterInfo.getHeader("slug")).thenReturn("document.docx"); - cut.processEvent(path, null, Attachments.create(), eventContext); + cut.processEvent( + path, null, Attachments.create(), eventContext, new AttachmentContext.Composition()); verify(attachmentService).createAttachment(contextArgumentCaptor.capture()); assertThat(contextArgumentCaptor.getValue().fileName()).isEqualTo("document.docx"); @@ -284,7 +312,8 @@ void fileNamePayloadPrecedesHeader() { when(parameterInfo.getHeader("Content-Disposition")) .thenReturn("attachment; filename=\"header-name.pdf\""); - cut.processEvent(path, null, Attachments.create(), eventContext); + cut.processEvent( + path, null, Attachments.create(), eventContext, new AttachmentContext.Composition()); verify(attachmentService).createAttachment(contextArgumentCaptor.capture()); assertThat(contextArgumentCaptor.getValue().fileName()).isEqualTo("payload-name.pdf"); @@ -300,7 +329,8 @@ void mimeTypeFromContentTypeHeader() { .thenReturn(new AttachmentModificationResult(false, "id", "test", null)); when(parameterInfo.getHeader("Content-Type")).thenReturn("image/jpeg; charset=utf-8"); - cut.processEvent(path, null, Attachments.create(), eventContext); + cut.processEvent( + path, null, Attachments.create(), eventContext, new AttachmentContext.Composition()); verify(attachmentService).createAttachment(contextArgumentCaptor.capture()); assertThat(contextArgumentCaptor.getValue().mimeType()).isEqualTo("image/jpeg"); @@ -318,7 +348,8 @@ void mimeTypePayloadPrecedesHeader() { .thenReturn(new AttachmentModificationResult(false, "id", "test", null)); when(parameterInfo.getHeader("Content-Type")).thenReturn("application/pdf"); - cut.processEvent(path, null, Attachments.create(), eventContext); + cut.processEvent( + path, null, Attachments.create(), eventContext, new AttachmentContext.Composition()); verify(attachmentService).createAttachment(contextArgumentCaptor.capture()); assertThat(contextArgumentCaptor.getValue().mimeType()).isEqualTo("text/plain"); @@ -332,11 +363,11 @@ void fileNameIgnoredForInvalidHeader() { when(target.keys()).thenReturn(Map.of("ID", attachment.getId())); when(attachmentService.createAttachment(any())) .thenReturn(new AttachmentModificationResult(false, "id", "test", null)); - // Header exists but has no valid filename pattern when(parameterInfo.getHeader("Content-Disposition")).thenReturn("inline"); when(parameterInfo.getHeader("slug")).thenReturn(null); - cut.processEvent(path, null, Attachments.create(), eventContext); + cut.processEvent( + path, null, Attachments.create(), eventContext, new AttachmentContext.Composition()); verify(attachmentService).createAttachment(contextArgumentCaptor.capture()); assertThat(contextArgumentCaptor.getValue().fileName()).isNull(); @@ -352,7 +383,8 @@ void mimeTypeFromHeaderWhenEmpty() { .thenReturn(new AttachmentModificationResult(false, "id", "test", null)); when(parameterInfo.getHeader("Content-Type")).thenReturn("text/csv"); - cut.processEvent(path, null, Attachments.create(), eventContext); + cut.processEvent( + path, null, Attachments.create(), eventContext, new AttachmentContext.Composition()); verify(attachmentService).createAttachment(contextArgumentCaptor.capture()); assertThat(contextArgumentCaptor.getValue().mimeType()).isEqualTo("text/csv"); @@ -368,10 +400,324 @@ void headersSkippedWhenParameterInfoIsNull() { .thenReturn(new AttachmentModificationResult(false, "id", "test", null)); when(eventContext.getParameterInfo()).thenReturn(null); - cut.processEvent(path, null, Attachments.create(), eventContext); + cut.processEvent( + path, null, Attachments.create(), eventContext, new AttachmentContext.Composition()); verify(attachmentService).createAttachment(contextArgumentCaptor.capture()); assertThat(contextArgumentCaptor.getValue().fileName()).isNull(); assertThat(contextArgumentCaptor.getValue().mimeType()).isNull(); } + + // --- Inline Attachment Tests --- + + @Test + void inlineContentIdAndStatusWrittenWithPrefix() { + when(entity.getQualifiedName()).thenReturn(TEST_FULL_NAME); + + Map values = new HashMap<>(); + values.put("ID", UUID.randomUUID().toString()); + values.put("profilePicture_mimeType", "image/png"); + values.put("profilePicture_fileName", "photo.png"); + when(target.values()).thenReturn(values); + when(target.keys()).thenReturn(Map.of("ID", values.get("ID"))); + + var content = mock(InputStream.class); + when(attachmentService.createAttachment(any())) + .thenReturn(new AttachmentModificationResult(false, "doc-123", "Clean", null)); + + cut.processEvent( + path, + content, + inlineAttachment("profilePicture"), + eventContext, + new AttachmentContext.Inline("profilePicture")); + + assertThat(values).containsEntry("profilePicture_contentId", "doc-123"); + assertThat(values).containsEntry("profilePicture_status", "Clean"); + } + + @Test + void inlinePrefixedFieldValuesPassedToService() { + when(entity.getQualifiedName()).thenReturn(TEST_FULL_NAME); + + Map values = new HashMap<>(); + values.put("ID", UUID.randomUUID().toString()); + values.put("profilePicture_mimeType", "image/jpeg"); + values.put("profilePicture_fileName", "avatar.jpg"); + when(target.values()).thenReturn(values); + when(target.keys()).thenReturn(Map.of("ID", values.get("ID"))); + + var content = mock(InputStream.class); + when(attachmentService.createAttachment(any())) + .thenReturn(new AttachmentModificationResult(false, "id", "ok", null)); + + cut.processEvent( + path, + content, + inlineAttachment("profilePicture"), + eventContext, + new AttachmentContext.Inline("profilePicture")); + + verify(attachmentService).createAttachment(contextArgumentCaptor.capture()); + var input = contextArgumentCaptor.getValue(); + assertThat(input.mimeType()).isEqualTo("image/jpeg"); + assertThat(input.fileName()).isEqualTo("avatar.jpg"); + assertThat(input.content()).isEqualTo(content); + } + + @Test + void inlineFallsBackToAttachmentObjectWhenPrefixedFieldMissing() { + when(entity.getQualifiedName()).thenReturn(TEST_FULL_NAME); + + Map values = new HashMap<>(); + values.put("ID", UUID.randomUUID().toString()); + when(target.values()).thenReturn(values); + when(target.keys()).thenReturn(Map.of("ID", values.get("ID"))); + + var content = mock(InputStream.class); + when(attachmentService.createAttachment(any())) + .thenReturn(new AttachmentModificationResult(false, "id", "ok", null)); + + var existingData = Attachments.create(); + existingData.setFileName("fallback.txt"); + existingData.setMimeType("text/plain"); + existingData.put("_inlinePrefix", "profilePicture"); + + cut.processEvent( + path, content, existingData, eventContext, new AttachmentContext.Inline("profilePicture")); + + verify(attachmentService).createAttachment(contextArgumentCaptor.capture()); + var input = contextArgumentCaptor.getValue(); + assertThat(input.mimeType()).isEqualTo("text/plain"); + assertThat(input.fileName()).isEqualTo("fallback.txt"); + } + + @Test + void nonInlineEntityDoesNotUsePrefixedFields() { + when(entity.getQualifiedName()).thenReturn(TEST_FULL_NAME); + + Map values = new HashMap<>(); + values.put("ID", UUID.randomUUID().toString()); + values.put(MediaData.MIME_TYPE, "application/pdf"); + values.put(MediaData.FILE_NAME, "doc.pdf"); + when(target.values()).thenReturn(values); + when(target.keys()).thenReturn(Map.of("ID", values.get("ID"))); + + var content = mock(InputStream.class); + when(attachmentService.createAttachment(any())) + .thenReturn(new AttachmentModificationResult(false, "doc-999", "ok", null)); + + cut.processEvent( + path, content, Attachments.create(), eventContext, new AttachmentContext.Composition()); + + assertThat(values).containsEntry(Attachments.CONTENT_ID, "doc-999"); + assertThat(values).containsEntry(Attachments.STATUS, "ok"); + } + + @Test + void processEventWritesScannedAtWhenNonNull() { + when(entity.getQualifiedName()).thenReturn(TEST_FULL_NAME); + + Map values = new HashMap<>(); + values.put("ID", UUID.randomUUID().toString()); + values.put("profilePicture_mimeType", "image/png"); + values.put("profilePicture_fileName", "photo.png"); + when(target.values()).thenReturn(values); + when(target.keys()).thenReturn(Map.of("ID", values.get("ID"))); + + var scannedAt = java.time.Instant.now(); + var content = mock(InputStream.class); + when(attachmentService.createAttachment(any())) + .thenReturn(new AttachmentModificationResult(false, "doc-scan", "Clean", scannedAt)); + + cut.processEvent( + path, + content, + inlineAttachment("profilePicture"), + eventContext, + new AttachmentContext.Inline("profilePicture")); + + assertThat(values).containsEntry("profilePicture_contentId", "doc-scan"); + assertThat(values).containsEntry("profilePicture_status", "Clean"); + assertThat(values).containsEntry("profilePicture_scannedAt", scannedAt); + } + + // --- Inline Header Extraction Tests --- + + private Map prepareInlineValuesWithoutMetadata() { + when(entity.getQualifiedName()).thenReturn(TEST_FULL_NAME); + + Map values = new HashMap<>(); + values.put("ID", UUID.randomUUID().toString()); + when(target.values()).thenReturn(values); + when(target.keys()).thenReturn(Map.of("ID", values.get("ID"))); + when(attachmentService.createAttachment(any())) + .thenReturn(new AttachmentModificationResult(false, "id", "ok", null)); + return values; + } + + @Test + void inlineExtractsFileNameFromRfc5987Header() { + Map values = prepareInlineValuesWithoutMetadata(); + when(parameterInfo.getHeader("Content-Disposition")) + .thenReturn("attachment; filename*=UTF-8''my%20file.txt"); + + cut.processEvent( + path, + mock(InputStream.class), + inlineAttachment("profilePicture"), + eventContext, + new AttachmentContext.Inline("profilePicture")); + + assertThat(values).containsEntry("profilePicture_fileName", "my file.txt"); + assertThat(values).doesNotContainKey(MediaData.FILE_NAME); + verify(attachmentService).createAttachment(contextArgumentCaptor.capture()); + assertThat(contextArgumentCaptor.getValue().fileName()).isEqualTo("my file.txt"); + } + + @Test + void inlineExtractsFileNameFromPlainHeader() { + Map values = prepareInlineValuesWithoutMetadata(); + when(parameterInfo.getHeader("Content-Disposition")) + .thenReturn("attachment; filename=\"report.pdf\""); + + cut.processEvent( + path, + mock(InputStream.class), + inlineAttachment("profilePicture"), + eventContext, + new AttachmentContext.Inline("profilePicture")); + + assertThat(values).containsEntry("profilePicture_fileName", "report.pdf"); + verify(attachmentService).createAttachment(contextArgumentCaptor.capture()); + assertThat(contextArgumentCaptor.getValue().fileName()).isEqualTo("report.pdf"); + } + + @Test + void inlineExtractsFileNameFromSlugHeader() { + Map values = prepareInlineValuesWithoutMetadata(); + when(parameterInfo.getHeader("Content-Disposition")).thenReturn(null); + when(parameterInfo.getHeader("slug")).thenReturn("slug-file.png"); + + cut.processEvent( + path, + mock(InputStream.class), + inlineAttachment("profilePicture"), + eventContext, + new AttachmentContext.Inline("profilePicture")); + + assertThat(values).containsEntry("profilePicture_fileName", "slug-file.png"); + verify(attachmentService).createAttachment(contextArgumentCaptor.capture()); + assertThat(contextArgumentCaptor.getValue().fileName()).isEqualTo("slug-file.png"); + } + + @Test + void inlineBothHeadersNullReturnsEmptyFileName() { + Map values = prepareInlineValuesWithoutMetadata(); + when(parameterInfo.getHeader("Content-Disposition")).thenReturn(null); + when(parameterInfo.getHeader("slug")).thenReturn(null); + + cut.processEvent( + path, + mock(InputStream.class), + inlineAttachment("profilePicture"), + eventContext, + new AttachmentContext.Inline("profilePicture")); + + assertThat(values).doesNotContainKey("profilePicture_fileName"); + verify(attachmentService).createAttachment(contextArgumentCaptor.capture()); + assertThat(contextArgumentCaptor.getValue().fileName()).isNull(); + } + + @Test + void inlineExtractsMimeTypeFromContentTypeHeader() { + Map values = prepareInlineValuesWithoutMetadata(); + when(parameterInfo.getHeader("Content-Type")).thenReturn("image/jpeg; charset=utf-8"); + + cut.processEvent( + path, + mock(InputStream.class), + inlineAttachment("profilePicture"), + eventContext, + new AttachmentContext.Inline("profilePicture")); + + assertThat(values).containsEntry("profilePicture_mimeType", "image/jpeg"); + assertThat(values).doesNotContainKey(MediaData.MIME_TYPE); + verify(attachmentService).createAttachment(contextArgumentCaptor.capture()); + assertThat(contextArgumentCaptor.getValue().mimeType()).isEqualTo("image/jpeg"); + } + + @Test + void inlineMimeTypeOctetStreamKeptWhenExplicitlySet() { + Map values = prepareInlineValuesWithoutMetadata(); + values.put("profilePicture_mimeType", "application/octet-stream"); + when(parameterInfo.getHeader("Content-Type")).thenReturn("image/png"); + + cut.processEvent( + path, + mock(InputStream.class), + inlineAttachment("profilePicture"), + eventContext, + new AttachmentContext.Inline("profilePicture")); + + assertThat(values).containsEntry("profilePicture_mimeType", "application/octet-stream"); + verify(attachmentService).createAttachment(contextArgumentCaptor.capture()); + assertThat(contextArgumentCaptor.getValue().mimeType()).isEqualTo("application/octet-stream"); + } + + @Test + void inlineMimeTypeNullContentTypeReturnsEmpty() { + Map values = prepareInlineValuesWithoutMetadata(); + when(parameterInfo.getHeader("Content-Type")).thenReturn(null); + + cut.processEvent( + path, + mock(InputStream.class), + inlineAttachment("profilePicture"), + eventContext, + new AttachmentContext.Inline("profilePicture")); + + assertThat(values).doesNotContainKey("profilePicture_mimeType"); + verify(attachmentService).createAttachment(contextArgumentCaptor.capture()); + assertThat(contextArgumentCaptor.getValue().mimeType()).isNull(); + } + + @Test + void inlineMimeTypeOctetStreamFromContentTypeHeaderIsUsed() { + Map values = prepareInlineValuesWithoutMetadata(); + when(parameterInfo.getHeader("Content-Type")).thenReturn("application/octet-stream"); + + cut.processEvent( + path, + mock(InputStream.class), + inlineAttachment("profilePicture"), + eventContext, + new AttachmentContext.Inline("profilePicture")); + + assertThat(values).containsEntry("profilePicture_mimeType", "application/octet-stream"); + verify(attachmentService).createAttachment(contextArgumentCaptor.capture()); + assertThat(contextArgumentCaptor.getValue().mimeType()).isEqualTo("application/octet-stream"); + } + + @Test + void inlineFileNameAlreadyPresentSkipsHeaderExtraction() { + Map values = prepareInlineValuesWithoutMetadata(); + values.put("profilePicture_fileName", "already-set.pdf"); + + cut.processEvent( + path, + mock(InputStream.class), + inlineAttachment("profilePicture"), + eventContext, + new AttachmentContext.Inline("profilePicture")); + + verify(parameterInfo, never()).getHeader("Content-Disposition"); + assertThat(values).containsEntry("profilePicture_fileName", "already-set.pdf"); + } + + private static Attachments inlineAttachment(String prefix) { + Attachments attachment = Attachments.create(); + attachment.put("_inlinePrefix", prefix); + return attachment; + } } diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/DoNothingAttachmentEventTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/DoNothingAttachmentEventTest.java index 2a0cecb82..bd7b95291 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/DoNothingAttachmentEventTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/DoNothingAttachmentEventTest.java @@ -9,6 +9,7 @@ import static org.mockito.Mockito.when; import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments; +import com.sap.cds.feature.attachments.handler.common.AttachmentContext; import com.sap.cds.ql.cqn.Path; import com.sap.cds.ql.cqn.ResolvedSegment; import com.sap.cds.reflect.CdsElement; @@ -49,7 +50,9 @@ void contentReturned(String input) { when(target.entity()).thenReturn(entity); when(entity.getQualifiedName()).thenReturn("some.qualified.name"); - var result = cut.processEvent(path, streamInput, data, mock(EventContext.class)); + var result = + cut.processEvent( + path, streamInput, data, mock(EventContext.class), new AttachmentContext.Composition()); assertThat(result).isEqualTo(streamInput); verifyNoInteractions(element, data); diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/MarkAsDeletedAttachmentEventTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/MarkAsDeletedAttachmentEventTest.java index 18e95854c..6d71e6dd1 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/MarkAsDeletedAttachmentEventTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/MarkAsDeletedAttachmentEventTest.java @@ -10,6 +10,10 @@ import static org.mockito.Mockito.when; import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments; +import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.MediaData; +import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.testservice.RootTable_; +import com.sap.cds.feature.attachments.handler.common.AttachmentContext; +import com.sap.cds.feature.attachments.handler.helper.RuntimeHelper; import com.sap.cds.feature.attachments.service.AttachmentService; import com.sap.cds.feature.attachments.service.model.service.MarkAsDeletedInput; import com.sap.cds.ql.cqn.Path; @@ -31,6 +35,7 @@ class MarkAsDeletedAttachmentEventTest { private MarkAsDeletedAttachmentEvent cut; private AttachmentService attachmentService; private Path path; + private ResolvedSegment target; private Map currentData; private EventContext context; private UserInfo userInfo; @@ -42,9 +47,13 @@ void setup() { context = mock(EventContext.class); path = mock(Path.class); - var target = mock(ResolvedSegment.class); + target = mock(ResolvedSegment.class); currentData = new HashMap<>(); when(path.target()).thenReturn(target); + // Default: non-inline entity (mock with no elements → getInlineAttachmentFieldNames returns + // empty) + var entity = mock(CdsEntity.class); + when(target.entity()).thenReturn(entity); var eventTarget = mock(CdsEntity.class); when(context.getTarget()).thenReturn(eventTarget); when(eventTarget.getQualifiedName()).thenReturn("some.qualified.name"); @@ -60,7 +69,8 @@ void documentIsExternallyDeleted() { var data = Attachments.create(); data.setContentId(contentId); - var expectedValue = cut.processEvent(path, value, data, context); + var expectedValue = + cut.processEvent(path, value, data, context, new AttachmentContext.Composition()); assertThat(expectedValue).isEqualTo(value); assertThat(data.getContentId()).isEqualTo(contentId); @@ -71,7 +81,9 @@ void documentIsExternallyDeleted() { assertThat(currentData) .containsEntry(Attachments.CONTENT_ID, null) .containsEntry(Attachments.STATUS, null) - .containsEntry(Attachments.SCANNED_AT, null); + .containsEntry(Attachments.SCANNED_AT, null) + .doesNotContainKey(MediaData.MIME_TYPE) + .doesNotContainKey(MediaData.FILE_NAME); } @Test @@ -79,12 +91,16 @@ void documentIsNotExternallyDeletedBecauseDoesNotExistBefore() { var value = new ByteArrayInputStream("test".getBytes(StandardCharsets.UTF_8)); var data = Attachments.create(); - var expectedValue = cut.processEvent(path, value, data, context); + var expectedValue = + cut.processEvent(path, value, data, context, new AttachmentContext.Composition()); assertThat(expectedValue).isEqualTo(value); assertThat(data.getContentId()).isNull(); verifyNoInteractions(attachmentService); - assertThat(currentData).containsEntry(Attachments.CONTENT_ID, null); + assertThat(currentData) + .containsEntry(Attachments.CONTENT_ID, null) + .doesNotContainKey(MediaData.MIME_TYPE) + .doesNotContainKey(MediaData.FILE_NAME); } @Test @@ -95,12 +111,16 @@ void documentIsNotExternallyDeletedBecauseItIsDraftChangeEvent() { data.setContentId(contentId); when(context.getEvent()).thenReturn(DraftService.EVENT_DRAFT_PATCH); - var expectedValue = cut.processEvent(path, value, data, context); + var expectedValue = + cut.processEvent(path, value, data, context, new AttachmentContext.Composition()); assertThat(expectedValue).isEqualTo(value); assertThat(data.getContentId()).isEqualTo(contentId); verifyNoInteractions(attachmentService); - assertThat(currentData).containsEntry(Attachments.CONTENT_ID, null); + assertThat(currentData) + .containsEntry(Attachments.CONTENT_ID, null) + .doesNotContainKey(MediaData.MIME_TYPE) + .doesNotContainKey(MediaData.FILE_NAME); } @Test @@ -110,7 +130,8 @@ void processEvent_withNullPath_doesNotModifyPathValues() { var data = Attachments.create(); data.setContentId(contentId); - var expectedValue = cut.processEvent(null, value, data, context); + var expectedValue = + cut.processEvent(null, value, data, context, new AttachmentContext.Composition()); assertThat(expectedValue).isEqualTo(value); // Attachment service should still be called to mark as deleted @@ -131,7 +152,8 @@ void processEvent_withDifferentNewContentId_doesNotClearContentId() { // Set a different contentId in the path values currentData.put(Attachments.CONTENT_ID, newContentId); - var expectedValue = cut.processEvent(path, value, data, context); + var expectedValue = + cut.processEvent(path, value, data, context, new AttachmentContext.Composition()); assertThat(expectedValue).isEqualTo(value); // Attachment service should be called to mark old content as deleted @@ -141,4 +163,63 @@ void processEvent_withDifferentNewContentId_doesNotClearContentId() { // currentData should NOT be cleared since newContentId differs from attachment.getContentId() assertThat(currentData).containsEntry(Attachments.CONTENT_ID, newContentId); } + + // --- Inline Attachment Tests --- + + @Test + void inlineDelete_clearsPrefixedFields() { + // Use real entity from CDS model so that getInlineAttachmentFieldNames returns + // ["profilePicture"] + CdsEntity realEntity = + RuntimeHelper.runtime.getCdsModel().findEntity(RootTable_.CDS_NAME).orElseThrow(); + when(target.entity()).thenReturn(realEntity); + + Map values = new HashMap<>(); + values.put("ID", "some-id"); + values.put("profilePicture_contentId", "old-content-id"); + values.put("profilePicture_status", "Clean"); + values.put("profilePicture_mimeType", "image/png"); + values.put("profilePicture_fileName", "photo.png"); + when(target.values()).thenReturn(values); + + var data = Attachments.create(); + data.setContentId("old-content-id"); + data.put("_inlinePrefix", "profilePicture"); + when(context.getEvent()).thenReturn(DraftService.EVENT_DRAFT_PATCH); + + cut.processEvent(path, null, data, context, new AttachmentContext.Inline("profilePicture")); + + // All prefixed fields should be cleared + assertThat(values) + .containsEntry("profilePicture_contentId", null) + .containsEntry("profilePicture_status", null) + .containsEntry("profilePicture_scannedAt", null) + .containsEntry("profilePicture_mimeType", null) + .containsEntry("profilePicture_fileName", null); + // Unprefixed fields should NOT be set + assertThat(values).doesNotContainKey(Attachments.CONTENT_ID); + assertThat(values).doesNotContainKey(Attachments.STATUS); + } + + @Test + void inlineDelete_withDifferentNewContentId_doesNotClearPrefixedFields() { + CdsEntity realEntity = + RuntimeHelper.runtime.getCdsModel().findEntity(RootTable_.CDS_NAME).orElseThrow(); + when(target.entity()).thenReturn(realEntity); + + Map values = new HashMap<>(); + values.put("ID", "some-id"); + values.put("profilePicture_contentId", "different-new-content-id"); + when(target.values()).thenReturn(values); + + var data = Attachments.create(); + data.setContentId("old-content-id"); + data.put("_inlinePrefix", "profilePicture"); + + cut.processEvent(path, null, data, context, new AttachmentContext.Inline("profilePicture")); + + // contentId differs from attachment's contentId, so fields should NOT be cleared + assertThat(values).containsEntry("profilePicture_contentId", "different-new-content-id"); + assertThat(values).doesNotContainKey("profilePicture_status"); + } } diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/UpdateAttachmentEventTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/UpdateAttachmentEventTest.java index 640a304a3..bad6e0832 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/UpdateAttachmentEventTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/UpdateAttachmentEventTest.java @@ -8,6 +8,7 @@ import static org.mockito.Mockito.when; import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments; +import com.sap.cds.feature.attachments.handler.common.AttachmentContext; import com.sap.cds.ql.cqn.Path; import com.sap.cds.ql.cqn.ResolvedSegment; import com.sap.cds.reflect.CdsEntity; @@ -44,9 +45,22 @@ void eventsCorrectCalled() { var existingData = Attachments.create(); var eventContext = mock(EventContext.class); - cut.processEvent(path, testContentStream, existingData, eventContext); + cut.processEvent( + path, testContentStream, existingData, eventContext, new AttachmentContext.Composition()); - verify(createEvent).processEvent(path, testContentStream, existingData, eventContext); - verify(deleteEvent).processEvent(path, testContentStream, existingData, eventContext); + verify(createEvent) + .processEvent( + path, + testContentStream, + existingData, + eventContext, + new AttachmentContext.Composition()); + verify(deleteEvent) + .processEvent( + path, + testContentStream, + existingData, + eventContext, + new AttachmentContext.Composition()); } } diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/readhelper/BeforeReadItemsModifierTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/readhelper/BeforeReadItemsModifierTest.java index 3c2444cc2..136ef5dd2 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/readhelper/BeforeReadItemsModifierTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/readhelper/BeforeReadItemsModifierTest.java @@ -198,4 +198,119 @@ private void runTestForDirectSelectScannedAt(CqnSelect select, int expectedField .count(); assertThat(count).isEqualTo(expectedFieldCount); } + + // --- Inline attachment modifier tests --- + + @Test + void inlineAttachmentFieldsAreAdded() { + CqnSelect select = + Select.from(RootTable_.class) + .columns(RootTable_::ID, RootTable_::title, b -> b.get("profilePicture_content")); + + cut = new BeforeReadItemsModifier(List.of(), List.of("profilePicture")); + List resultItems = cut.items(select.items()); + + var contentIdCount = + resultItems.stream() + .filter( + item -> + item.isRef() && item.asRef().displayName().equals("profilePicture_contentId")) + .count(); + var statusCount = + resultItems.stream() + .filter( + item -> item.isRef() && item.asRef().displayName().equals("profilePicture_status")) + .count(); + var scannedAtCount = + resultItems.stream() + .filter( + item -> + item.isRef() && item.asRef().displayName().equals("profilePicture_scannedAt")) + .count(); + assertThat(contentIdCount).isEqualTo(1); + assertThat(statusCount).isEqualTo(1); + assertThat(scannedAtCount).isEqualTo(1); + } + + @Test + void inlineAttachmentFieldsNotDuplicatedIfAlreadyPresent() { + CqnSelect select = + Select.from(RootTable_.class) + .columns(RootTable_::ID, b -> b.get("profilePicture_contentId")); + + cut = new BeforeReadItemsModifier(List.of(), List.of("profilePicture")); + List resultItems = cut.items(select.items()); + + var contentIdCount = + resultItems.stream() + .filter( + item -> + item.isRef() && item.asRef().displayName().equals("profilePicture_contentId")) + .count(); + assertThat(contentIdCount).isEqualTo(1); + } + + @Test + void emptyInlinePrefixesDoNotAddFields() { + CqnSelect select = Select.from(RootTable_.class).columns(RootTable_::ID, RootTable_::title); + + cut = new BeforeReadItemsModifier(List.of(), List.of()); + List resultItems = cut.items(select.items()); + + var inlineFieldCount = + resultItems.stream() + .filter( + item -> item.isRef() && item.asRef().displayName().startsWith("profilePicture_")) + .count(); + assertThat(inlineFieldCount).isEqualTo(0); + } + + @Test + void inlineAttachmentFieldsNotAddedWithoutContentInSelect() { + // When profilePicture_content is NOT in the select (e.g. SELECT ID, title), + // the modifier must NOT add profilePicture_contentId/status. Otherwise it + // would convert a SELECT * into a partial column list, breaking draftPrepare. + CqnSelect select = Select.from(RootTable_.class).columns(RootTable_::ID, RootTable_::title); + + cut = new BeforeReadItemsModifier(List.of(), List.of("profilePicture")); + List resultItems = cut.items(select.items()); + + var inlineFieldCount = + resultItems.stream() + .filter( + item -> item.isRef() && item.asRef().displayName().startsWith("profilePicture_")) + .count(); + assertThat(inlineFieldCount).isEqualTo(0); + } + + @Test + void inlineFieldsNotAddedWhenContentIdAlreadySelected() { + // Both profilePicture_content AND profilePicture_contentId are explicitly selected. + // The modifier should NOT add duplicate contentId/status/scannedAt fields. + CqnSelect select = + Select.from(RootTable_.class) + .columns( + RootTable_::ID, + b -> b.get("profilePicture_content"), + b -> b.get("profilePicture_contentId")); + + cut = new BeforeReadItemsModifier(List.of(), List.of("profilePicture")); + List resultItems = cut.items(select.items()); + + // contentId already in select, so it should appear exactly once (no duplicate added) + var contentIdCount = + resultItems.stream() + .filter( + item -> + item.isRef() && item.asRef().displayName().equals("profilePicture_contentId")) + .count(); + assertThat(contentIdCount).isEqualTo(1); + // status and scannedAt should NOT be added either since the guard prevents it + var statusCount = + resultItems.stream() + .filter( + item -> item.isRef() && item.asRef().displayName().equals("profilePicture_status")) + .count(); + assertThat(statusCount).isEqualTo(0); + } } diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/readhelper/CountingInputStreamTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/readhelper/CountingInputStreamTest.java index e1245d79b..d031eaac0 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/readhelper/CountingInputStreamTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/readhelper/CountingInputStreamTest.java @@ -309,4 +309,14 @@ void constructor_fractionalValue_throwsServiceException() { assertThat(exception.getMessage()).contains("Error parsing max size annotation value"); assertThat(exception.getCause()).isInstanceOf(ArithmeticException.class); } + + @Test + void close_withNullDelegate_doesNotThrow() { + // CountingInputStream.close() guards against null delegate + assertDoesNotThrow( + () -> { + var cut = new CountingInputStream(null, "100"); + cut.close(); + }); + } } diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/common/ApplicationHandlerHelperTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/common/ApplicationHandlerHelperTest.java index e7d8cfa33..a0de90824 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 @@ -8,11 +8,29 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import com.sap.cds.CdsData; +import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments; +import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.EventItems_; +import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.testservice.InlineOnly_; +import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.testservice.RootTable; +import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.testservice.RootTable_; +import com.sap.cds.feature.attachments.handler.helper.RuntimeHelper; +import com.sap.cds.reflect.CdsEntity; +import com.sap.cds.services.runtime.CdsRuntime; +import java.util.List; import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; class ApplicationHandlerHelperTest { + private static CdsRuntime runtime; + + @BeforeAll + static void classSetup() { + runtime = RuntimeHelper.runtime; + } + @Test void keysAreInData() { Map keys = Map.of("key1", "value1", "key2", "value2"); @@ -57,4 +75,83 @@ void removeDraftKey() { assertFalse(result.containsKey("IsActiveEntity")); assertTrue(result.containsKey("key1")); } + + @Test + void getInlineAttachmentFieldNamesReturnsPrefix() { + CdsEntity entity = runtime.getCdsModel().findEntity(RootTable_.CDS_NAME).orElseThrow(); + List fieldNames = ApplicationHandlerHelper.getInlineAttachmentFieldNames(entity); + + assertThat(fieldNames).contains("profilePicture"); + } + + @Test + void getInlineAttachmentFieldNamesReturnsEmptyForNonInlineEntity() { + CdsEntity entity = runtime.getCdsModel().findEntity(EventItems_.CDS_NAME).orElseThrow(); + List fieldNames = ApplicationHandlerHelper.getInlineAttachmentFieldNames(entity); + + assertThat(fieldNames).isEmpty(); + } + + @Test + void getInlineAttachmentPrefixReturnsMatchingPrefix() { + CdsEntity entity = runtime.getCdsModel().findEntity(RootTable_.CDS_NAME).orElseThrow(); + Optional prefix = + ApplicationHandlerHelper.getInlineAttachmentPrefix(entity, "profilePicture_content"); + + assertThat(prefix).isPresent().hasValue("profilePicture"); + } + + @Test + void getInlineAttachmentPrefixReturnsEmptyForNonMatchingElement() { + CdsEntity entity = runtime.getCdsModel().findEntity(RootTable_.CDS_NAME).orElseThrow(); + Optional prefix = ApplicationHandlerHelper.getInlineAttachmentPrefix(entity, "title"); + + assertThat(prefix).isEmpty(); + } + + @Test + void condenseAttachmentsExtractsInlineAttachments() { + CdsEntity entity = runtime.getCdsModel().findEntity(RootTable_.CDS_NAME).orElseThrow(); + var data = CdsData.create(); + data.put(RootTable.PROFILE_PICTURE_CONTENT_ID, "inline-cid-1"); + data.put(RootTable.PROFILE_PICTURE_STATUS, "Clean"); + data.put(RootTable.PROFILE_PICTURE_CONTENT, null); + + List result = ApplicationHandlerHelper.condenseAttachments(List.of(data), entity); + + assertThat(result).hasSize(1); + assertThat(result.get(0).getContentId()).isEqualTo("inline-cid-1"); + assertThat(result.get(0).get(ApplicationHandlerHelper.INLINE_PREFIX_MARKER)) + .isEqualTo("profilePicture"); + } + + @Test + void condenseAttachmentsDedupsByContentId() { + CdsEntity entity = runtime.getCdsModel().findEntity(InlineOnly_.CDS_NAME).orElseThrow(); + var data = CdsData.create(); + data.put("avatar_contentId", "same-cid"); + data.put("avatar_content", null); + + List result = ApplicationHandlerHelper.condenseAttachments(List.of(data), entity); + + assertThat(result).hasSize(1); + } + + @Test + void extractInlineAttachmentExtractsPrefixedFields() { + Map parentValues = + Map.of( + "profilePicture_contentId", "cid-123", + "profilePicture_status", "Clean", + "title", "test root"); + + Attachments attachment = + ApplicationHandlerHelper.extractInlineAttachment(parentValues, "profilePicture"); + + assertThat(attachment.getContentId()).isEqualTo("cid-123"); + assertThat(attachment.getStatus()).isEqualTo("Clean"); + assertThat(attachment.get(ApplicationHandlerHelper.INLINE_PREFIX_MARKER)) + .isEqualTo("profilePicture"); + assertThat(attachment.containsKey("title")).isFalse(); + } } diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftCancelAttachmentsHandlerTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftCancelAttachmentsHandlerTest.java index 3a23f8547..ccd92eb60 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftCancelAttachmentsHandlerTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftCancelAttachmentsHandlerTest.java @@ -6,13 +6,17 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.*; +import com.sap.cds.CdsData; import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments; import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.testservice.Attachment; import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.testservice.Attachment_; +import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.testservice.RootTable; import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.testservice.RootTable_; import com.sap.cds.feature.attachments.handler.applicationservice.modifyevents.MarkAsDeletedAttachmentEvent; +import com.sap.cds.feature.attachments.handler.common.AttachmentContext; import com.sap.cds.feature.attachments.handler.common.AttachmentsReader; import com.sap.cds.feature.attachments.handler.helper.RuntimeHelper; import com.sap.cds.ql.Delete; @@ -183,7 +187,12 @@ void createdEntityNeedsToBeDeleted() { cut.processBeforeDraftCancel(eventContext); verify(deleteContentAttachmentEvent) - .processEvent(any(), eq(null), dataArgumentCaptor.capture(), eq(eventContext)); + .processEvent( + any(), + eq(null), + dataArgumentCaptor.capture(), + eq(eventContext), + any(AttachmentContext.class)); assertThat(dataArgumentCaptor.getValue()).isEqualTo(attachment); } @@ -202,7 +211,12 @@ void updatedEntityNeedsToBeDeleted() { cut.processBeforeDraftCancel(eventContext); verify(deleteContentAttachmentEvent) - .processEvent(any(), eq(null), dataArgumentCaptor.capture(), eq(eventContext)); + .processEvent( + any(), + eq(null), + dataArgumentCaptor.capture(), + eq(eventContext), + any(AttachmentContext.class)); assertThat(dataArgumentCaptor.getValue()).isEqualTo(draftAttachment); } @@ -268,7 +282,106 @@ void noMatchingActiveEntryForDraftAttachment() { cut.processBeforeDraftCancel(eventContext); - // Should not call deleteEvent since keys don't match + // Orphan prevention: draft has contentId but no matching active entry, so delete it + verify(deleteContentAttachmentEvent) + .processEvent(isNull(), isNull(), any(), eq(eventContext), any(AttachmentContext.class)); + } + + @Test + void inlineAttachmentWithoutActiveEntityDeletesContent() { + getEntityAndMockContext(RootTable_.CDS_NAME); + CqnDelete delete = Delete.from(RootTable_.class); + when(eventContext.getCqn()).thenReturn(delete); + when(eventContext.getModel()).thenReturn(runtime.getCdsModel()); + when(eventContext.getEvent()).thenReturn("DRAFT_CANCEL"); + + var id = UUID.randomUUID().toString(); + + CdsData draftRoot = CdsData.create(); + draftRoot.put(RootTable.ID, id); + draftRoot.put(RootTable.PROFILE_PICTURE_CONTENT_ID, "new-content-id"); + draftRoot.put(Drafts.HAS_ACTIVE_ENTITY, false); + + when(attachmentsReader.readAttachments(any(), any(), any())) + .thenReturn(List.of(Attachments.of(draftRoot))) + .thenReturn(List.of()); + + cut.processBeforeDraftCancel(eventContext); + + verify(deleteContentAttachmentEvent) + .processEvent( + any(), + eq(null), + dataArgumentCaptor.capture(), + eq(eventContext), + any(AttachmentContext.class)); + assertThat(dataArgumentCaptor.getValue().getContentId()).isEqualTo("new-content-id"); + assertThat(dataArgumentCaptor.getValue().get("_inlinePrefix")).isEqualTo("profilePicture"); + } + + @Test + void inlineAttachmentWithActiveEntityAndChangedContentIdDeletesContent() { + getEntityAndMockContext(RootTable_.CDS_NAME); + CqnDelete delete = Delete.from(RootTable_.class); + when(eventContext.getCqn()).thenReturn(delete); + when(eventContext.getModel()).thenReturn(runtime.getCdsModel()); + when(eventContext.getEvent()).thenReturn("DRAFT_CANCEL"); + + var id = UUID.randomUUID().toString(); + + CdsData draftRoot = CdsData.create(); + draftRoot.put(RootTable.ID, id); + draftRoot.put(RootTable.PROFILE_PICTURE_CONTENT_ID, "new-content-id"); + draftRoot.put(Drafts.HAS_ACTIVE_ENTITY, true); + + CdsData activeRoot = CdsData.create(); + activeRoot.put(RootTable.ID, id); + activeRoot.put(RootTable.PROFILE_PICTURE_CONTENT_ID, "old-content-id"); + activeRoot.put(RootTable.PROFILE_PICTURE_CONTENT, null); + + when(attachmentsReader.readAttachments(any(), any(), any())) + .thenReturn(List.of(Attachments.of(draftRoot))) + .thenReturn(List.of(Attachments.of(activeRoot))); + + cut.processBeforeDraftCancel(eventContext); + + verify(deleteContentAttachmentEvent) + .processEvent( + any(), + eq(null), + dataArgumentCaptor.capture(), + eq(eventContext), + any(AttachmentContext.class)); + assertThat(dataArgumentCaptor.getValue().getContentId()).isEqualTo("new-content-id"); + } + + @Test + void inlineAttachmentWithActiveEntityAndSameContentIdDoesNotDelete() { + getEntityAndMockContext(RootTable_.CDS_NAME); + CqnDelete delete = Delete.from(RootTable_.class); + when(eventContext.getCqn()).thenReturn(delete); + when(eventContext.getModel()).thenReturn(runtime.getCdsModel()); + when(eventContext.getEvent()).thenReturn("DRAFT_CANCEL"); + + var id = UUID.randomUUID().toString(); + var contentId = UUID.randomUUID().toString(); + + CdsData draftRoot = CdsData.create(); + draftRoot.put(RootTable.ID, id); + draftRoot.put(RootTable.PROFILE_PICTURE_CONTENT_ID, contentId); + draftRoot.put(Drafts.HAS_ACTIVE_ENTITY, true); + + CdsData activeRoot = CdsData.create(); + activeRoot.put(RootTable.ID, id); + activeRoot.put(RootTable.PROFILE_PICTURE_CONTENT_ID, contentId); + activeRoot.put(RootTable.PROFILE_PICTURE_CONTENT, null); + + when(attachmentsReader.readAttachments(any(), any(), any())) + .thenReturn(List.of(Attachments.of(draftRoot))) + .thenReturn(List.of(Attachments.of(activeRoot))); + + cut.processBeforeDraftCancel(eventContext); + verifyNoInteractions(deleteContentAttachmentEvent); } 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 035dd766b..9f4c2c89e 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 @@ -10,11 +10,15 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import com.sap.cds.CdsData; import com.sap.cds.Result; +import com.sap.cds.Struct; import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments; import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.Events; import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.Events_; import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.testservice.Attachment_; +import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.testservice.InlineOnly; +import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.testservice.InlineOnly_; import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.testservice.Items; import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.testservice.RootTable; import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.testservice.RootTable_; @@ -24,6 +28,7 @@ import com.sap.cds.feature.attachments.handler.applicationservice.readhelper.CountingInputStream; import com.sap.cds.feature.attachments.handler.helper.RuntimeHelper; import com.sap.cds.ql.cqn.CqnSelect; +import com.sap.cds.ql.cqn.CqnUpdate; import com.sap.cds.reflect.CdsEntity; import com.sap.cds.services.draft.DraftPatchEventContext; import com.sap.cds.services.draft.DraftService; @@ -34,7 +39,9 @@ import com.sap.cds.services.request.ParameterInfo; import com.sap.cds.services.runtime.CdsRuntime; import java.io.InputStream; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.UUID; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; @@ -141,7 +148,7 @@ void contentIdUsedForEventFactory() { InputStream captured = streamCaptor.getValue(); assertThat(captured).isInstanceOf(CountingInputStream.class); assertThat(((CountingInputStream) captured).getDelegate()).isSameAs(content); - verify(event).processEvent(any(), eq(captured), eq(attachment), eq(eventContext)); + verify(event).processEvent(any(), eq(captured), eq(attachment), eq(eventContext), any()); } @Test @@ -155,6 +162,34 @@ void contentIdIsNotSetForNonMediaEntity() { assertThat(events).doesNotContainKey(Attachments.CONTENT_ID); } + @Test + void inlineMetadataUpdateIncludesEntityKeysInWhereClause() { + getEntityAndMockContext(InlineOnly_.CDS_NAME); + var entityId = UUID.randomUUID().toString(); + var contentId = UUID.randomUUID().toString(); + + // Build data simulating post-converter state where contentId and mimeType have been set + Map data = new HashMap<>(); + data.put(InlineOnly.ID, entityId); + data.put(InlineOnly.AVATAR_CONTENT_ID, contentId); + data.put(InlineOnly.AVATAR_MIME_TYPE, "image/png"); + + when(persistence.run(any(CqnSelect.class))).thenReturn(mock(Result.class)); + when(persistence.run(any(CqnUpdate.class))).thenReturn(mock(Result.class)); + + cut.processBeforeDraftPatch(eventContext, List.of(Struct.access(data).as(CdsData.class))); + + ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(CqnUpdate.class); + verify(persistence).run(updateCaptor.capture()); + var update = updateCaptor.getValue(); + assertThat(update.where()).isPresent(); + var where = update.where().get().toString(); + assertThat(where).contains("avatar_contentId"); + assertThat(where).contains(contentId); + assertThat(where).contains("ID"); + assertThat(where).contains(entityId); + } + @Test void classHasCorrectAnnotations() { var serviceAnnotation = cut.getClass().getAnnotation(ServiceName.class); diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/AttachmentsServiceImplTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/AttachmentsServiceImplTest.java index c1f569f35..883f18ccd 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/AttachmentsServiceImplTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/AttachmentsServiceImplTest.java @@ -9,6 +9,7 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +import com.sap.cds.feature.attachments.handler.common.AttachmentContext; import com.sap.cds.feature.attachments.service.model.service.CreateAttachmentInput; import com.sap.cds.feature.attachments.service.model.service.MarkAsDeletedInput; import com.sap.cds.feature.attachments.service.model.servicehandler.AttachmentCreateEventContext; @@ -97,7 +98,13 @@ void createAttachmentInsertsData(Boolean isExternalCreated) { var stream = mock(InputStream.class); Map ids = Map.of("ID1", "value1", "id2", "Value2"); var input = - new CreateAttachmentInput(ids, mock(CdsEntity.class), "fileName", "mimeType", stream); + new CreateAttachmentInput( + ids, + mock(CdsEntity.class), + "fileName", + "mimeType", + stream, + new AttachmentContext.Composition()); var result = cut.createAttachment(input); @@ -125,7 +132,12 @@ void createAttachmentExternalCreateNotFilledReturnedFalse() { Map ids = Map.of("ID1", "value1", "id2", "Value2"); var input = new CreateAttachmentInput( - ids, mock(CdsEntity.class), "fileName", "mimeType", mock(InputStream.class)); + ids, + mock(CdsEntity.class), + "fileName", + "mimeType", + mock(InputStream.class), + new AttachmentContext.Composition()); var result = cut.createAttachment(input); diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/handler/AttachmentsServiceImplHandlerTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/handler/AttachmentsServiceImplHandlerTest.java index 6c9f120a6..647cf119c 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/handler/AttachmentsServiceImplHandlerTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/handler/AttachmentsServiceImplHandlerTest.java @@ -13,6 +13,7 @@ import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.MediaData; import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.StatusCode; import com.sap.cds.feature.attachments.generated.test.cds4j.sap.attachments.Attachments; +import com.sap.cds.feature.attachments.handler.common.AttachmentContext; import com.sap.cds.feature.attachments.service.handler.transaction.EndTransactionMalwareScanProvider; import com.sap.cds.feature.attachments.service.model.servicehandler.AttachmentCreateEventContext; import com.sap.cds.feature.attachments.service.model.servicehandler.AttachmentMarkAsDeletedEventContext; @@ -93,7 +94,9 @@ void readAttachmentSetData() { void malwareScannerRegisteredForEndOfTransaction() { var listener = mock(ChangeSetListener.class); var entity = mock(CdsEntity.class); - when(malwareScanProvider.getChangeSetListener(entity, "contentId")).thenReturn(listener); + when(malwareScanProvider.getChangeSetListener( + entity, "contentId", new AttachmentContext.Composition())) + .thenReturn(listener); var createContext = AttachmentCreateEventContext.create(); createContext.setAttachmentIds(Map.of(Attachments.ID, "contentId")); createContext.setData(MediaData.create()); @@ -103,7 +106,8 @@ void malwareScannerRegisteredForEndOfTransaction() { cut.createAttachment(createContext); cut.afterCreateAttachment(createContext); - verify(malwareScanProvider).getChangeSetListener(entity, "contentId"); + verify(malwareScanProvider) + .getChangeSetListener(entity, "contentId", new AttachmentContext.Composition()); } @Test @@ -125,7 +129,7 @@ void createAttachment_emptyAttachmentIds_handlesGracefully() { @Test void afterCreateAttachment_noChangeSetContext_throws() { var entity = mock(CdsEntity.class); - when(malwareScanProvider.getChangeSetListener(any(), any())) + when(malwareScanProvider.getChangeSetListener(any(), any(), any())) .thenReturn(mock(ChangeSetListener.class)); var createContext = AttachmentCreateEventContext.create(); createContext.setAttachmentIds(Map.of(Attachments.ID, "some-id")); diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/handler/transaction/EndTransactionMalwareScanRunnerTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/handler/transaction/EndTransactionMalwareScanRunnerTest.java index 59bc339e8..f5f82695c 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/handler/transaction/EndTransactionMalwareScanRunnerTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/handler/transaction/EndTransactionMalwareScanRunnerTest.java @@ -12,6 +12,7 @@ import static org.mockito.Mockito.when; import ch.qos.logback.classic.Level; +import com.sap.cds.feature.attachments.handler.common.AttachmentContext; import com.sap.cds.feature.attachments.helper.LogObserver; import com.sap.cds.feature.attachments.service.malware.AttachmentMalwareScanner; import com.sap.cds.reflect.CdsEntity; @@ -70,7 +71,11 @@ void setup() { attachmentMalwareScanner = mock(AttachmentMalwareScanner.class); cut = new EndTransactionMalwareScanRunner( - attachmentEntity, contentId, attachmentMalwareScanner, runtime); + attachmentEntity, + contentId, + new AttachmentContext.Composition(), + attachmentMalwareScanner, + runtime); observer = LogObserver.create(cut.getClass().getName()); } @@ -88,7 +93,7 @@ void notCompletedTransactionDoNothing() { return null; }) .when(attachmentMalwareScanner) - .scanAttachment(attachmentEntity, contentId); + .scanAttachment(attachmentEntity, contentId, new AttachmentContext.Composition()); cut.afterClose(false); @@ -111,12 +116,13 @@ void completedTransactionScanAttachments() { return null; }) .when(attachmentMalwareScanner) - .scanAttachment(attachmentEntity, contentId); + .scanAttachment(attachmentEntity, contentId, new AttachmentContext.Composition()); cut.afterClose(true); Awaitility.await().until(executionDone::get); - verify(attachmentMalwareScanner).scanAttachment(attachmentEntity, contentId); + verify(attachmentMalwareScanner) + .scanAttachment(attachmentEntity, contentId, new AttachmentContext.Composition()); assertThat(usedThread.get()).isNotEmpty().isNotEqualTo(Thread.currentThread().getName()); } @@ -127,7 +133,7 @@ void exceptionDuringScanningLogged() { throw new RuntimeException("Some exception"); }) .when(attachmentMalwareScanner) - .scanAttachment(attachmentEntity, contentId); + .scanAttachment(attachmentEntity, contentId, new AttachmentContext.Composition()); observer.start(); cut.afterClose(true); @@ -146,12 +152,13 @@ void directScanCallScanAttachments() { return null; }) .when(attachmentMalwareScanner) - .scanAttachment(attachmentEntity, contentId); + .scanAttachment(attachmentEntity, contentId, new AttachmentContext.Composition()); - cut.scanAsync(attachmentEntity, contentId); + cut.scanAsync(attachmentEntity, contentId, new AttachmentContext.Composition()); Awaitility.await().until(executionDone::get); - verify(attachmentMalwareScanner).scanAttachment(attachmentEntity, contentId); + verify(attachmentMalwareScanner) + .scanAttachment(attachmentEntity, contentId, new AttachmentContext.Composition()); assertThat(usedThread.get()).isNotEmpty().isNotEqualTo(Thread.currentThread().getName()); } @@ -162,10 +169,10 @@ void exceptionDuringScanningLoggedForDirectScanCall() { throw new RuntimeException("Some exception"); }) .when(attachmentMalwareScanner) - .scanAttachment(attachmentEntity, contentId); + .scanAttachment(attachmentEntity, contentId, new AttachmentContext.Composition()); observer.start(); - cut.scanAsync(attachmentEntity, contentId); + cut.scanAsync(attachmentEntity, contentId, new AttachmentContext.Composition()); verifyLogIsWritten(); } diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/malware/DefaultAttachmentMalwareScannerTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/malware/DefaultAttachmentMalwareScannerTest.java index 343cd6b15..432bac978 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/malware/DefaultAttachmentMalwareScannerTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/malware/DefaultAttachmentMalwareScannerTest.java @@ -14,9 +14,11 @@ import static org.mockito.Mockito.when; import com.sap.cds.Result; +import com.sap.cds.Row; import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments; import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.StatusCode; import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.Attachment_; +import com.sap.cds.feature.attachments.handler.common.AttachmentContext; import com.sap.cds.feature.attachments.handler.helper.RuntimeHelper; import com.sap.cds.feature.attachments.service.AttachmentService; import com.sap.cds.feature.attachments.service.malware.client.MalwareScanClient; @@ -71,7 +73,7 @@ void correctSelectForNonDraftEntity() { var entity = runtime.getCdsModel().findEntity(Attachment_.CDS_NAME); when(persistenceService.run(any(CqnSelect.class))).thenReturn(result); - cut.scanAttachment(entity.orElseThrow(), "ID"); + cut.scanAttachment(entity.orElseThrow(), "ID", new AttachmentContext.Composition()); verify(persistenceService).run(selectCaptor.capture()); var select = selectCaptor.getValue(); @@ -84,7 +86,7 @@ void correctSelectForDraftEntity() { var entity = runtime.getCdsModel().findEntity(getTestServiceAttachmentName()); mockSelectResult(Attachments.create(), MalwareScanResultStatus.CLEAN); - cut.scanAttachment(entity.orElseThrow(), "ID"); + cut.scanAttachment(entity.orElseThrow(), "ID", new AttachmentContext.Composition()); verify(persistenceService, times(2)).run(selectCaptor.capture()); var selects = selectCaptor.getAllValues(); @@ -107,10 +109,14 @@ void fallbackToActiveEntityIfDraftHasNoData() { var content = mock(InputStream.class); var cdsData = Attachments.create(); cdsData.setContent(content); + cdsData.setId("test-key-id"); when(result.single(Attachments.class)).thenReturn(cdsData); + var row = mock(Row.class); + when(row.get("ID")).thenReturn("test-key-id"); + when(result.single()).thenReturn(row); when(malwareScanClient.scanContent(any())).thenReturn(MalwareScanResultStatus.CLEAN); - cut.scanAttachment(entity.orElseThrow(), "ID"); + cut.scanAttachment(entity.orElseThrow(), "ID", new AttachmentContext.Composition()); verify(malwareScanClient).scanContent(content); verify(persistenceService, times(2)).run(selectCaptor.capture()); @@ -129,7 +135,9 @@ void exceptionIfTooManyResultsAreSelected() { when(persistenceService.run(any(CqnSelect.class))).thenReturn(result); when(result.rowCount()).thenReturn(2L); - assertThrows(IllegalStateException.class, () -> cut.scanAttachment(entity, "")); + assertThrows( + IllegalStateException.class, + () -> cut.scanAttachment(entity, "", new AttachmentContext.Composition())); } @ParameterizedTest @@ -138,7 +146,7 @@ void dataAreUpdatedWithStatus(MalwareScanResultStatus status) { var entity = runtime.getCdsModel().findEntity(getTestServiceAttachmentName()); mockSelectResult(Attachments.create(), status); - cut.scanAttachment(entity.orElseThrow(), "ID"); + cut.scanAttachment(entity.orElseThrow(), "ID", new AttachmentContext.Composition()); verifyPersistenceServiceCalledCorrectlyForReadAndUpdate(status); } @@ -148,11 +156,16 @@ void dataAreUpdatedWithStatusFromFailingScanClient() { var entity = runtime.getCdsModel().findEntity(getTestServiceAttachmentName()); when(persistenceService.run(any(CqnSelect.class))).thenReturn(result); when(result.rowCount()).thenReturn(1L); - when(result.single(Attachments.class)).thenReturn(Attachments.create()); + var data = Attachments.create(); + data.setId("test-key-id"); + when(result.single(Attachments.class)).thenReturn(data); + var row = mock(Row.class); + when(row.get("ID")).thenReturn("test-key-id"); + when(result.single()).thenReturn(row); when(malwareScanClient.scanContent(any())) .thenThrow(new ServiceException("Error reading attachment")); - cut.scanAttachment(entity.orElseThrow(), "ID"); + cut.scanAttachment(entity.orElseThrow(), "ID", new AttachmentContext.Composition()); verifyPersistenceServiceCalledCorrectlyForReadAndUpdate(MalwareScanResultStatus.FAILED); } @@ -162,11 +175,16 @@ void dataAreUpdatedWithStatusFromFailingAttachmentService() { var entity = runtime.getCdsModel().findEntity(getTestServiceAttachmentName()); when(persistenceService.run(any(CqnSelect.class))).thenReturn(result); when(result.rowCount()).thenReturn(1L); - when(result.single(Attachments.class)).thenReturn(Attachments.create()); + var data = Attachments.create(); + data.setId("test-key-id"); + when(result.single(Attachments.class)).thenReturn(data); + var row = mock(Row.class); + when(row.get("ID")).thenReturn("test-key-id"); + when(result.single()).thenReturn(row); when(attachmentService.readAttachment(any())) .thenThrow(new ServiceException("Error reading attachment")); - cut.scanAttachment(entity.orElseThrow(), "ID"); + cut.scanAttachment(entity.orElseThrow(), "ID", new AttachmentContext.Composition()); verifyPersistenceServiceCalledCorrectlyForReadAndUpdate(MalwareScanResultStatus.FAILED); } @@ -179,7 +197,7 @@ void contentTakenFromTheDatabaseSelect() { data.put("content", content); mockSelectResult(data, MalwareScanResultStatus.CLEAN); - cut.scanAttachment(entity.orElseThrow(), ""); + cut.scanAttachment(entity.orElseThrow(), "", new AttachmentContext.Composition()); verify(malwareScanClient, times(1)).scanContent(content); verifyNoInteractions(attachmentService); @@ -195,7 +213,7 @@ void contentTakenFromTheAttachmentService() { var content = mock(InputStream.class); when(attachmentService.readAttachment(contentId)).thenReturn(content); - cut.scanAttachment(entity.orElseThrow(), ""); + cut.scanAttachment(entity.orElseThrow(), "", new AttachmentContext.Composition()); verify(attachmentService, times(1)).readAttachment(contentId); verify(malwareScanClient, times(1)).scanContent(content); @@ -211,7 +229,7 @@ void contentTakenFromTheAttachmentServiceForNonDraft() { var content = mock(InputStream.class); when(attachmentService.readAttachment(contentId)).thenReturn(content); - cut.scanAttachment(entity.orElseThrow(), ""); + cut.scanAttachment(entity.orElseThrow(), "", new AttachmentContext.Composition()); verify(attachmentService, times(1)).readAttachment(contentId); verify(malwareScanClient, times(1)).scanContent(content); @@ -225,12 +243,16 @@ void updateAttemptedForAllEntitiesEvenWhenActiveHasNoData() { var originSelectionData = Attachments.create(); originSelectionData.setContentId("first contentId"); originSelectionData.setContent(mock(InputStream.class)); + originSelectionData.setId("test-key-id"); when(result.single(Attachments.class)) .thenReturn(originSelectionData) .thenReturn(Attachments.create()); + var row = mock(Row.class); + when(row.get("ID")).thenReturn("test-key-id"); + when(result.single()).thenReturn(row); when(malwareScanClient.scanContent(any())).thenReturn(MalwareScanResultStatus.CLEAN); - cut.scanAttachment(entity.orElseThrow(), "ID"); + cut.scanAttachment(entity.orElseThrow(), "ID", new AttachmentContext.Composition()); verify(persistenceService, times(2)).run(updateCaptor.capture()); var updateList = updateCaptor.getAllValues(); @@ -244,12 +266,16 @@ void clientNotCalledIfNoInstanceBound() { var entity = runtime.getCdsModel().findEntity(getTestServiceAttachmentName()); var secondResult = mock(Result.class); when(secondResult.rowCount()).thenReturn(0L); - when(secondResult.single(Attachments.class)).thenReturn(Attachments.create()); when(persistenceService.run(any(CqnSelect.class))).thenReturn(result).thenReturn(secondResult); when(result.rowCount()).thenReturn(1L); - when(result.single(Attachments.class)).thenReturn(Attachments.create()); + var data = Attachments.create(); + data.setId("test-key-id"); + when(result.single(Attachments.class)).thenReturn(data); + var row = mock(Row.class); + when(row.get("ID")).thenReturn("test-key-id"); + when(result.single()).thenReturn(row); - cut.scanAttachment(entity.orElseThrow(), "ID"); + cut.scanAttachment(entity.orElseThrow(), "ID", new AttachmentContext.Composition()); verifyNoInteractions(malwareScanClient); verify(persistenceService, times(2)).run(updateCaptor.capture()); @@ -270,10 +296,14 @@ void scanResultWrittenToAllEntitiesEvenIfDraftDeletedDuringScanning() { var content = mock(InputStream.class); var draftData = Attachments.create(); draftData.setContent(content); + draftData.setId("test-key-id"); // Phase 1: draft has the row, active does not when(persistenceService.run(any(CqnSelect.class))).thenReturn(result); when(result.rowCount()).thenReturn(1L).thenReturn(0L); when(result.single(Attachments.class)).thenReturn(draftData); + var row = mock(Row.class); + when(row.get("ID")).thenReturn("test-key-id"); + when(result.single()).thenReturn(row); when(malwareScanClient.scanContent(any())).thenReturn(MalwareScanResultStatus.CLEAN); // Phase 2: simulate draft deleted (0 rows updated), active now has the row (1 row updated) var draftUpdateResult = mock(Result.class); @@ -284,7 +314,7 @@ void scanResultWrittenToAllEntitiesEvenIfDraftDeletedDuringScanning() { .thenReturn(draftUpdateResult) .thenReturn(activeUpdateResult); - cut.scanAttachment(entity.orElseThrow(), "ID"); + cut.scanAttachment(entity.orElseThrow(), "ID", new AttachmentContext.Composition()); // Scan should happen once from draft content verify(malwareScanClient).scanContent(content); @@ -306,12 +336,45 @@ void noScanOrUpdateWhenAttachmentNotFoundInAnyEntity() { when(emptyResult.rowCount()).thenReturn(0L); when(persistenceService.run(any(CqnSelect.class))).thenReturn(emptyResult); - cut.scanAttachment(entity.orElseThrow(), "ID"); + cut.scanAttachment(entity.orElseThrow(), "ID", new AttachmentContext.Composition()); verifyNoInteractions(malwareScanClient); verify(persistenceService, times(0)).run(any(CqnUpdate.class)); } + @Test + void scanAttachmentWithInlinePrefixExtractsFromPrefixedColumns() { + var entity = + runtime + .getCdsModel() + .findEntity( + com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.testservice + .InlineOnly_.CDS_NAME) + .orElseThrow(); + var content = mock(InputStream.class); + var row = mock(Row.class); + when(row.get("avatar_contentId")).thenReturn("inline-cid"); + when(row.get("avatar_content")).thenReturn(content); + when(row.get("avatar_status")).thenReturn(StatusCode.UNSCANNED); + when(persistenceService.run(any(CqnSelect.class))).thenReturn(result); + when(result.rowCount()).thenReturn(1L); + when(result.single()).thenReturn(row); + when(malwareScanClient.scanContent(any())).thenReturn(MalwareScanResultStatus.CLEAN); + + cut.scanAttachment(entity, "inline-cid", new AttachmentContext.Inline("avatar")); + + verify(malwareScanClient).scanContent(content); + verify(persistenceService, times(2)).run(updateCaptor.capture()); + var updates = updateCaptor.getAllValues(); + assertThat(updates).hasSize(2); + updates.forEach( + update -> { + assertThat(update.entries()).hasSize(1); + assertThat(update.entries().get(0)).containsEntry("avatar_status", StatusCode.CLEAN); + assertThat(update.entries().get(0)).containsKey("avatar_scannedAt"); + }); + } + @Test void mapStatus() { assertEquals( @@ -352,9 +415,13 @@ private String getTestServiceAttachmentName() { } private void mockSelectResult(Attachments cdsData, MalwareScanResultStatus status) { + cdsData.setId("test-key-id"); when(persistenceService.run(any(CqnSelect.class))).thenReturn(result); when(result.rowCount()).thenReturn(1L); when(result.single(Attachments.class)).thenReturn(cdsData); + var row = mock(Row.class); + when(row.get("ID")).thenReturn("test-key-id"); + when(result.single()).thenReturn(row); when(malwareScanClient.scanContent(any())).thenReturn(status); } diff --git a/cds-feature-attachments/src/test/resources/cds/db-model.cds b/cds-feature-attachments/src/test/resources/cds/db-model.cds index 25d91921d..0c7e5934b 100644 --- a/cds-feature-attachments/src/test/resources/cds/db-model.cds +++ b/cds-feature-attachments/src/test/resources/cds/db-model.cds @@ -1,17 +1,20 @@ namespace unit.test; using {cuid} from '@sap/cds/common'; -using {sap.attachments.Attachments} from '../../../main/resources/cds/com.sap.cds/cds-feature-attachments'; +using { + Attachments, + Attachment as AttachmentType +} from '../../../main/resources/cds/com.sap.cds/cds-feature-attachments'; using from '@sap/cds/srv/outbox'; -entity Attachment : Attachments { -} +entity Attachment : Attachments {} entity Roots : cuid { - title : String; - itemTable : Composition of many Items - on itemTable.rootId = $self.ID; - attachments : Composition of many Attachments; + title : String; + itemTable : Composition of many Items + on itemTable.rootId = $self.ID; + attachments : Composition of many Attachments; + profilePicture : AttachmentType; } entity Items : cuid { @@ -19,7 +22,8 @@ entity Items : cuid { note : String; events : Composition of many Events on events.id1 = $self.ID; - attachments : Composition of many Attachment on attachments.ID = $self.ID; + attachments : Composition of many Attachment + on attachments.ID = $self.ID; itemAttachments : Composition of many Attachments; } @@ -36,12 +40,18 @@ entity Events { } entity EventItems { - key id1 : UUID; - note : String; - sizeLimitedAttachments : Composition of many Attachments; + key id1 : UUID; + note : String; + sizeLimitedAttachments : Composition of many Attachments; defaultSizeLimitedAttachments : Composition of many Attachments; } +// Entity with only inline attachment (no composition) +entity InlineOnly : cuid { + title : String; + avatar : AttachmentType; +} + annotate EventItems.sizeLimitedAttachments with { content @Validation.Maximum: '10KB'; }; @@ -50,3 +60,6 @@ annotate EventItems.defaultSizeLimitedAttachments with { content @Validation.Maximum; }; +annotate InlineOnly : avatar with { + content @Validation.Maximum: '10KB'; +}; diff --git a/cds-feature-attachments/src/test/resources/cds/service.cds b/cds-feature-attachments/src/test/resources/cds/service.cds index 5541ac775..331c6173f 100644 --- a/cds-feature-attachments/src/test/resources/cds/service.cds +++ b/cds-feature-attachments/src/test/resources/cds/service.cds @@ -5,4 +5,6 @@ using unit.test as db from './db-model'; service TestService { @odata.draft.enabled entity RootTable as projection on db.Roots; + @odata.draft.enabled + entity InlineOnly as projection on db.InlineOnly; } diff --git a/doc/CHANGELOG.md b/doc/CHANGELOG.md index 48e2afcf9..5e69d1202 100644 --- a/doc/CHANGELOG.md +++ b/doc/CHANGELOG.md @@ -8,9 +8,13 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/). ## Version 1.6.0 - not yet released -### Changed +### Added +- Added support for single (inline) attachments via the `Attachment` type. Requires cds-services 4.9.0 or higher. (#768) - Added top-level `Attachments` aspect to allow usage without `sap.attachments` namespace (#806), i.e., `using {Attachments} from 'com.sap.cds/cds-feature-attachments'`. + +### Changed + - Extract `fileName` and `mimeType` from HTTP headers (`Content-Disposition`, `Content-Type`, `slug`) when not provided in the request payload (#804) ## Version 1.5.0 - 2026-04-10 diff --git a/integration-tests/db/data-model.cds b/integration-tests/db/data-model.cds index b0cc7aa76..900618b97 100644 --- a/integration-tests/db/data-model.cds +++ b/integration-tests/db/data-model.cds @@ -1,7 +1,10 @@ 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, + sap.attachments.Attachment +} from 'com.sap.cds/cds-feature-attachments'; entity AttachmentEntity : Attachments { parentKey : UUID; @@ -9,6 +12,8 @@ entity AttachmentEntity : Attachments { entity Roots : cuid { title : String; + avatar : Attachment; + coverImage : Attachment; attachments : Composition of many AttachmentEntity on attachments.parentKey = $self.ID; items : Composition of many Items @@ -28,6 +33,7 @@ entity Contributors : cuid { entity Items : cuid { parentID : UUID; title : String; + icon : Attachment; events : Association to many Events on events.itemId = $self.ID; attachments : Composition of many Attachments; diff --git a/integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/draftservice/DraftOdataRequestValidationWithTestHandlerTest.java b/integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/draftservice/DraftOdataRequestValidationWithTestHandlerTest.java index 3f5a01f5f..3d3aacfca 100644 --- a/integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/draftservice/DraftOdataRequestValidationWithTestHandlerTest.java +++ b/integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/draftservice/DraftOdataRequestValidationWithTestHandlerTest.java @@ -129,6 +129,11 @@ protected void verifyOnlyTwoDeleteEvents( AttachmentService.EVENT_CREATE_ATTACHMENT, AttachmentService.EVENT_READ_ATTACHMENT); var deleteEvents = serviceHandler.getEventContextForEvent(AttachmentService.EVENT_MARK_ATTACHMENT_AS_DELETED); + deleteEvents.forEach( + e -> { + var ctx = (AttachmentMarkAsDeletedEventContext) e.context(); + logger.info("DELETE EVENT contentId={}", ctx.getContentId()); + }); assertThat(deleteEvents).hasSize(2); verifyDeleteEventContainsContentId(deleteEvents, attachmentContentId); verifyDeleteEventContainsContentId(deleteEvents, attachmentEntityContentId); diff --git a/integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/draftservice/SingleAttachmentDraftTest.java b/integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/draftservice/SingleAttachmentDraftTest.java new file mode 100644 index 000000000..e1f24c618 --- /dev/null +++ b/integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/draftservice/SingleAttachmentDraftTest.java @@ -0,0 +1,497 @@ +/* + * © 2024-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.Struct; +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.common.TableDataDeleter; +import com.sap.cds.feature.attachments.integrationtests.constants.Profiles; +import com.sap.cds.feature.attachments.integrationtests.testhandler.TestPersistenceHandler; +import com.sap.cds.feature.attachments.integrationtests.testhandler.TestPluginAttachmentsServiceHandler; +import com.sap.cds.feature.attachments.service.AttachmentService; +import com.sap.cds.feature.attachments.service.model.servicehandler.AttachmentCreateEventContext; +import com.sap.cds.feature.attachments.service.model.servicehandler.AttachmentMarkAsDeletedEventContext; +import com.sap.cds.feature.attachments.service.model.servicehandler.AttachmentReadEventContext; +import com.sap.cds.ql.Select; +import com.sap.cds.ql.StructuredType; +import com.sap.cds.services.persistence.PersistenceService; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.concurrent.TimeUnit; +import org.awaitility.Awaitility; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles(Profiles.TEST_HANDLER_ENABLED) +class SingleAttachmentDraftTest { + + private static final Logger logger = LoggerFactory.getLogger(SingleAttachmentDraftTest.class); + private static final String BASE_URL = MockHttpRequestHelper.ODATA_BASE_URL + "TestDraftService/"; + + @Autowired private TestPluginAttachmentsServiceHandler serviceHandler; + @Autowired private MockHttpRequestHelper requestHelper; + @Autowired private PersistenceService persistenceService; + @Autowired private TableDataDeleter dataDeleter; + @Autowired private TestPersistenceHandler testPersistenceHandler; + @Autowired private MockMvc mvc; + + @AfterEach + void teardown() { + dataDeleter.deleteData( + DraftRoots_.CDS_NAME, DraftRoots_.CDS_NAME + "_drafts", "cds.outbox.Messages"); + serviceHandler.clearEventContext(); + serviceHandler.clearDocuments(); + requestHelper.resetHelper(); + testPersistenceHandler.reset(); + } + + @Test + void createInlineAttachmentInDraftAndActivate() throws Exception { + var draft = createNewDraft(); + var draftRootUrl = getDraftRootUrl(draft.getId()); + + requestHelper.executePatchWithODataResponseAndAssertStatusOk( + draftRootUrl, "{\"title\":\"some title\"}"); + + var content = putInlineAttachmentContent(draftRootUrl, "avatarContent"); + prepareAndActivateDraft(draftRootUrl); + + var activeRoot = selectActiveRoot(draft.getId()); + assertThat(activeRoot.getAvatarContentId()).isNotEmpty(); + assertThat(activeRoot.getAvatarStatus()).isNotEmpty(); + verifySingleCreateEvent(activeRoot.getAvatarContentId(), content); + } + + @Test + void createInlineAttachmentInDraftAndCancel() throws Exception { + var draft = createNewDraft(); + var draftRootUrl = getDraftRootUrl(draft.getId()); + + var content = putInlineAttachmentContent(draftRootUrl, "avatarContent"); + cancelDraft(draftRootUrl); + + waitTillExpectedHandlerMessageSize(2); + var createEvents = + serviceHandler.getEventContextForEvent(AttachmentService.EVENT_CREATE_ATTACHMENT); + assertThat(createEvents).hasSize(1); + var createContext = (AttachmentCreateEventContext) createEvents.get(0).context(); + assertThat(createContext.getData().getContent().readAllBytes()) + .isEqualTo(content.getBytes(StandardCharsets.UTF_8)); + + var deleteEvents = + serviceHandler.getEventContextForEvent(AttachmentService.EVENT_MARK_ATTACHMENT_AS_DELETED); + assertThat(deleteEvents).hasSize(1); + var deleteContext = (AttachmentMarkAsDeletedEventContext) deleteEvents.get(0).context(); + assertThat(deleteContext.getContentId()).isEqualTo(createContext.getContentId()); + } + + @Test + void updateInlineAttachmentInDraftAndActivate() throws Exception { + var draft = createNewDraft(); + var draftRootUrl = getDraftRootUrl(draft.getId()); + putInlineAttachmentContent(draftRootUrl, "originalContent"); + prepareAndActivateDraft(draftRootUrl); + var activeRootAfterFirstActivation = selectActiveRoot(draft.getId()); + var originalContentId = activeRootAfterFirstActivation.getAvatarContentId(); + serviceHandler.clearEventContext(); + + editExistingRoot(draft.getId()); + var newDraftRootUrl = getDraftRootUrl(draft.getId()); + var newContent = putInlineAttachmentContent(newDraftRootUrl, "updatedContent"); + prepareAndActivateDraft(newDraftRootUrl); + + var activeRootAfterUpdate = selectActiveRoot(draft.getId()); + assertThat(activeRootAfterUpdate.getAvatarContentId()).isNotEmpty(); + assertThat(activeRootAfterUpdate.getAvatarContentId()).isNotEqualTo(originalContentId); + + waitTillExpectedHandlerMessageSize(2); + var createEvents = + serviceHandler.getEventContextForEvent(AttachmentService.EVENT_CREATE_ATTACHMENT); + assertThat(createEvents).hasSize(1); + var createContext = (AttachmentCreateEventContext) createEvents.get(0).context(); + assertThat(createContext.getContentId()).isEqualTo(activeRootAfterUpdate.getAvatarContentId()); + assertThat(createContext.getData().getContent().readAllBytes()) + .isEqualTo(newContent.getBytes(StandardCharsets.UTF_8)); + + var deleteEvents = + serviceHandler.getEventContextForEvent(AttachmentService.EVENT_MARK_ATTACHMENT_AS_DELETED); + assertThat(deleteEvents).hasSize(1); + var deleteContext = (AttachmentMarkAsDeletedEventContext) deleteEvents.get(0).context(); + assertThat(deleteContext.getContentId()).isEqualTo(originalContentId); + } + + @Test + void deleteInlineAttachmentInDraftAndActivate() throws Exception { + var draft = createNewDraft(); + var draftRootUrl = getDraftRootUrl(draft.getId()); + putInlineAttachmentContent(draftRootUrl, "contentToDelete"); + prepareAndActivateDraft(draftRootUrl); + var activeRootAfterFirstActivation = selectActiveRoot(draft.getId()); + var originalContentId = activeRootAfterFirstActivation.getAvatarContentId(); + serviceHandler.clearEventContext(); + + editExistingRoot(draft.getId()); + var newDraftRootUrl = getDraftRootUrl(draft.getId()); + requestHelper.executeDeleteWithMatcher( + newDraftRootUrl + "/avatar_content", status().isNoContent()); + prepareAndActivateDraft(newDraftRootUrl); + + var activeRootAfterDelete = selectActiveRoot(draft.getId()); + assertThat(activeRootAfterDelete.getAvatarContentId()).isNull(); + verifySingleDeletionEvent(originalContentId); + } + + @Test + void deleteInlineAttachmentInDraftAndCancel() throws Exception { + var draft = createNewDraft(); + var draftRootUrl = getDraftRootUrl(draft.getId()); + putInlineAttachmentContent(draftRootUrl, "contentToKeep"); + prepareAndActivateDraft(draftRootUrl); + var activeRootAfterFirstActivation = selectActiveRoot(draft.getId()); + assertThat(activeRootAfterFirstActivation.getAvatarContentId()).isNotEmpty(); + serviceHandler.clearEventContext(); + + editExistingRoot(draft.getId()); + var newDraftRootUrl = getDraftRootUrl(draft.getId()); + requestHelper.executeDeleteWithMatcher( + newDraftRootUrl + "/avatar_content", status().isNoContent()); + cancelDraft(newDraftRootUrl); + + verifyNoAttachmentEventsCalled(); + var activeRootAfterCancel = selectActiveRoot(draft.getId()); + assertThat(activeRootAfterCancel.getAvatarContentId()).isNotEmpty(); + } + + @Test + void contentReadableFromDraftBeforeActivation() throws Exception { + var draft = createNewDraft(); + var draftRootUrl = getDraftRootUrl(draft.getId()); + var content = putInlineAttachmentContent(draftRootUrl, "readableContent"); + serviceHandler.clearEventContext(); + + var contentUrl = draftRootUrl + "/avatar_content"; + Awaitility.await() + .atMost(60, TimeUnit.SECONDS) + .pollDelay(1, TimeUnit.SECONDS) + .pollInterval(2, TimeUnit.SECONDS) + .until( + () -> { + var response = requestHelper.executeGet(contentUrl); + var responseContent = response.getResponse().getContentAsString(); + var matches = responseContent.equals(content); + if (!matches) { + logger.info( + "Waiting for draft content to be readable. Response: '{}', Expected: '{}'", + responseContent, + content); + } + return matches; + }); + serviceHandler.clearEventContext(); + + var response = requestHelper.executeGet(contentUrl); + assertThat(response.getResponse().getContentAsString()).isEqualTo(content); + verifySingleReadEvent(null); + } + + @Test + void noChangesOnInlineAttachmentStillAvailableAfterActivate() throws Exception { + var draft = createNewDraft(); + var draftRootUrl = getDraftRootUrl(draft.getId()); + var content = putInlineAttachmentContent(draftRootUrl, "stableContent"); + prepareAndActivateDraft(draftRootUrl); + serviceHandler.clearEventContext(); + + editExistingRoot(draft.getId()); + var newDraftRootUrl = getDraftRootUrl(draft.getId()); + requestHelper.executePatchWithODataResponseAndAssertStatusOk( + newDraftRootUrl, "{\"title\":\"changed title\"}"); + prepareAndActivateDraft(newDraftRootUrl); + verifyNoAttachmentEventsCalled(); + + var activeContentUrl = getActiveRootUrl(draft.getId()) + "/avatar_content"; + Awaitility.await() + .atMost(60, TimeUnit.SECONDS) + .pollDelay(1, TimeUnit.SECONDS) + .pollInterval(2, TimeUnit.SECONDS) + .until( + () -> { + var response = requestHelper.executeGet(activeContentUrl); + return response.getResponse().getContentAsString().equals(content); + }); + serviceHandler.clearEventContext(); + + var response = requestHelper.executeGet(activeContentUrl); + assertThat(response.getResponse().getContentAsString()).isEqualTo(content); + } + + @Test + void errorInTransactionAfterCreateCallsDelete() throws Exception { + var draft = createNewDraft(); + var draftRootUrl = getDraftRootUrl(draft.getId()); + + testPersistenceHandler.setThrowExceptionOnUpdate(true); + var contentUrl = draftRootUrl + "/avatar_content"; + requestHelper.setContentType(MediaType.APPLICATION_OCTET_STREAM); + requestHelper.executePutWithMatcher( + contentUrl, "errorContent".getBytes(StandardCharsets.UTF_8), status().is5xxServerError()); + requestHelper.resetHelper(); + + waitTillExpectedHandlerMessageSize(2); + var createEvents = + serviceHandler.getEventContextForEvent(AttachmentService.EVENT_CREATE_ATTACHMENT); + assertThat(createEvents).hasSize(1); + var deleteEvents = + serviceHandler.getEventContextForEvent(AttachmentService.EVENT_MARK_ATTACHMENT_AS_DELETED); + assertThat(deleteEvents).hasSize(1); + var createContext = (AttachmentCreateEventContext) createEvents.get(0).context(); + var deleteContext = (AttachmentMarkAsDeletedEventContext) deleteEvents.get(0).context(); + assertThat(deleteContext.getContentId()).isEqualTo(createContext.getContentId()); + } + + @Test + void uploadWithContentDispositionHeaderInDraftPersistsFileName() throws Exception { + var draft = createNewDraft(); + var draftRootUrl = getDraftRootUrl(draft.getId()); + + var contentUrl = draftRootUrl + "/avatar_content"; + mvc.perform( + MockMvcRequestBuilders.put(contentUrl) + .contentType(MediaType.APPLICATION_OCTET_STREAM) + .header("Content-Disposition", "attachment; filename=\"draft-file.png\"") + .content("draft-content".getBytes(StandardCharsets.UTF_8))) + .andExpect(status().isNoContent()); + + prepareAndActivateDraft(draftRootUrl); + + var activeRoot = selectActiveRoot(draft.getId()); + assertThat(activeRoot.getAvatarContentId()).isNotEmpty(); + assertThat(activeRoot.getAvatarFileName()).isEqualTo("draft-file.png"); + } + + @Test + void multiEntityIsolation_activatingOneEntityDoesNotAffectOther() throws Exception { + var draftA = createNewDraft(); + var draftAUrl = getDraftRootUrl(draftA.getId()); + putInlineAttachmentContent(draftAUrl, "contentA"); + + var draftB = createNewDraft(); + var draftBUrl = getDraftRootUrl(draftB.getId()); + putInlineAttachmentContent(draftBUrl, "contentB"); + + prepareAndActivateDraft(draftAUrl); + + var activeRootA = selectActiveRoot(draftA.getId()); + assertThat(activeRootA.getAvatarContentId()).isNotEmpty(); + + prepareAndActivateDraft(draftBUrl); + + var activeRootB = selectActiveRoot(draftB.getId()); + assertThat(activeRootB.getAvatarContentId()).isNotEmpty(); + assertThat(activeRootB.getAvatarContentId()).isNotEqualTo(activeRootA.getAvatarContentId()); + + var contentBUrl = getActiveRootUrl(draftB.getId()) + "/avatar_content"; + Awaitility.await() + .atMost(60, TimeUnit.SECONDS) + .pollDelay(1, TimeUnit.SECONDS) + .pollInterval(2, TimeUnit.SECONDS) + .until( + () -> { + var response = requestHelper.executeGet(contentBUrl); + return response.getResponse().getContentAsString().equals("contentB"); + }); + + var response = requestHelper.executeGet(contentBUrl); + assertThat(response.getResponse().getContentAsString()).isEqualTo("contentB"); + } + + @Test + void putOversizedContentToCoverImageInDraftReturnsError() throws Exception { + var draft = createNewDraft(); + var draftRootUrl = getDraftRootUrl(draft.getId()); + + var url = draftRootUrl + "/coverImage_content"; + byte[] oversizedContent = new byte[6 * 1024 * 1024]; // 6MB > 5MB limit + requestHelper.setContentType(MediaType.APPLICATION_OCTET_STREAM); + requestHelper.executePutWithMatcher(url, oversizedContent, status().is4xxClientError()); + } + + @Test + void updateInlineAttachmentInDraftAndCancelDeletesNewContent() throws Exception { + var draft = createNewDraft(); + var draftRootUrl = getDraftRootUrl(draft.getId()); + putInlineAttachmentContent(draftRootUrl, "originalContent"); + prepareAndActivateDraft(draftRootUrl); + var activeRootAfterFirstActivation = selectActiveRoot(draft.getId()); + var originalContentId = activeRootAfterFirstActivation.getAvatarContentId(); + assertThat(originalContentId).isNotEmpty(); + serviceHandler.clearEventContext(); + + editExistingRoot(draft.getId()); + var newDraftRootUrl = getDraftRootUrl(draft.getId()); + putInlineAttachmentContent(newDraftRootUrl, "updatedContent"); + cancelDraft(newDraftRootUrl); + + waitTillExpectedHandlerMessageSize(2); + var createEvents = + serviceHandler.getEventContextForEvent(AttachmentService.EVENT_CREATE_ATTACHMENT); + assertThat(createEvents).hasSize(1); + var deleteEvents = + serviceHandler.getEventContextForEvent(AttachmentService.EVENT_MARK_ATTACHMENT_AS_DELETED); + assertThat(deleteEvents).hasSize(1); + var createContext = (AttachmentCreateEventContext) createEvents.get(0).context(); + var deleteContext = (AttachmentMarkAsDeletedEventContext) deleteEvents.get(0).context(); + assertThat(deleteContext.getContentId()).isEqualTo(createContext.getContentId()); + + var activeRootAfterCancel = selectActiveRoot(draft.getId()); + assertThat(activeRootAfterCancel.getAvatarContentId()).isEqualTo(originalContentId); + } + + // Helper methods + + private DraftRoots createNewDraft() throws Exception { + var responseData = + requestHelper.executePostWithODataResponseAndAssertStatusCreated( + BASE_URL + "DraftRoots", "{}"); + return Struct.access(responseData).as(DraftRoots.class); + } + + private String getDraftRootUrl(String rootId) { + return BASE_URL + "DraftRoots(ID=" + rootId + ",IsActiveEntity=false)"; + } + + private String getActiveRootUrl(String rootId) { + return BASE_URL + "DraftRoots(ID=" + rootId + ",IsActiveEntity=true)"; + } + + private void prepareAndActivateDraft(String draftRootUrl) throws Exception { + var draftPrepareUrl = draftRootUrl + "/TestDraftService.draftPrepare"; + var draftActivateUrl = draftRootUrl + "/TestDraftService.draftActivate"; + requestHelper.executePostWithMatcher( + draftPrepareUrl, "{\"SideEffectsQualifier\":\"\"}", status().isOk()); + requestHelper.executePostWithMatcher(draftActivateUrl, "{}", status().isOk()); + } + + private void editExistingRoot(String rootId) throws Exception { + var url = getActiveRootUrl(rootId) + "/TestDraftService.draftEdit"; + requestHelper.executePostWithMatcher(url, "{\"PreserveChanges\":true}", status().isOk()); + } + + private void cancelDraft(String draftRootUrl) throws Exception { + requestHelper.executeDeleteWithMatcher(draftRootUrl, status().isNoContent()); + } + + private String putInlineAttachmentContent(String draftRootUrl, String content) throws Exception { + var contentUrl = draftRootUrl + "/avatar_content"; + requestHelper.setContentType(MediaType.APPLICATION_OCTET_STREAM); + requestHelper.executePutWithMatcher( + contentUrl, content.getBytes(StandardCharsets.UTF_8), status().isNoContent()); + requestHelper.resetHelper(); + return content; + } + + private DraftRoots selectActiveRoot(String rootId) { + var select = + Select.from(DraftRoots_.CDS_NAME) + .where(root -> root.get(DraftRoots.ID).eq(rootId)) + .columns(StructuredType::_all); + return persistenceService.run(select).single(DraftRoots.class); + } + + private void verifySingleCreateEvent(String contentId, String content) { + verifyEventContextEmptyForEvent( + AttachmentService.EVENT_READ_ATTACHMENT, + AttachmentService.EVENT_MARK_ATTACHMENT_AS_DELETED); + var createEvents = + serviceHandler.getEventContextForEvent(AttachmentService.EVENT_CREATE_ATTACHMENT); + assertThat(createEvents) + .hasSize(1) + .first() + .satisfies( + event -> { + assertThat(event.context()).isInstanceOf(AttachmentCreateEventContext.class); + var createContext = (AttachmentCreateEventContext) event.context(); + assertThat(createContext.getContentId()).isEqualTo(contentId); + assertThat(createContext.getData().getContent().readAllBytes()) + .isEqualTo(content.getBytes(StandardCharsets.UTF_8)); + }); + } + + private void verifySingleDeletionEvent(String contentId) { + waitTillExpectedHandlerMessageSize(1); + verifyEventContextEmptyForEvent( + AttachmentService.EVENT_CREATE_ATTACHMENT, AttachmentService.EVENT_READ_ATTACHMENT); + var deleteEvents = + serviceHandler.getEventContextForEvent(AttachmentService.EVENT_MARK_ATTACHMENT_AS_DELETED); + assertThat(deleteEvents) + .hasSize(1) + .first() + .satisfies( + event -> { + assertThat(event.context()).isInstanceOf(AttachmentMarkAsDeletedEventContext.class); + var deleteContext = (AttachmentMarkAsDeletedEventContext) event.context(); + assertThat(deleteContext.getContentId()).isEqualTo(contentId); + assertThat(deleteContext.getDeletionUserInfo().getName()).isEqualTo("anonymous"); + assertThat(deleteContext.getDeletionUserInfo().getIsSystemUser()).isFalse(); + }); + } + + private void verifySingleReadEvent(String contentId) { + verifyEventContextEmptyForEvent( + AttachmentService.EVENT_CREATE_ATTACHMENT, + AttachmentService.EVENT_MARK_ATTACHMENT_AS_DELETED); + var readContext = serviceHandler.getEventContext(); + assertThat(readContext) + .hasSize(1) + .first() + .satisfies( + event -> { + assertThat(event.event()).isEqualTo(AttachmentService.EVENT_READ_ATTACHMENT); + if (contentId != null) { + assertThat(((AttachmentReadEventContext) event.context()).getContentId()) + .isEqualTo(contentId); + } + }); + } + + private void verifyNoAttachmentEventsCalled() { + assertThat(serviceHandler.getEventContext()).isEmpty(); + } + + private void verifyEventContextEmptyForEvent(String... events) { + Arrays.stream(events) + .forEach(event -> assertThat(serviceHandler.getEventContextForEvent(event)).isEmpty()); + } + + private void waitTillExpectedHandlerMessageSize(int expectedSize) { + Awaitility.await() + .atMost(30, TimeUnit.SECONDS) + .pollDelay(1, TimeUnit.SECONDS) + .until( + () -> { + var eventCalls = serviceHandler.getEventContext().size(); + logger.debug( + "Waiting for expected size '{}' in handler context, was '{}'", + expectedSize, + eventCalls); + return eventCalls >= expectedSize; + }); + } +} diff --git a/integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/SingleAttachmentNonDraftTest.java b/integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/SingleAttachmentNonDraftTest.java new file mode 100644 index 000000000..fcfc4408d --- /dev/null +++ b/integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/SingleAttachmentNonDraftTest.java @@ -0,0 +1,909 @@ +/* + * © 2024-2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. + */ +package com.sap.cds.feature.attachments.integrationtests.nondraftservice; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.sap.cds.feature.attachments.generated.integration.test.cds4j.testservice.Items; +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.common.TableDataDeleter; +import com.sap.cds.feature.attachments.integrationtests.constants.Profiles; +import com.sap.cds.feature.attachments.integrationtests.testhandler.EventContextHolder; +import com.sap.cds.feature.attachments.integrationtests.testhandler.TestPersistenceHandler; +import com.sap.cds.feature.attachments.integrationtests.testhandler.TestPluginAttachmentsServiceHandler; +import com.sap.cds.feature.attachments.service.AttachmentService; +import com.sap.cds.feature.attachments.service.model.servicehandler.AttachmentCreateEventContext; +import com.sap.cds.feature.attachments.service.model.servicehandler.AttachmentMarkAsDeletedEventContext; +import com.sap.cds.feature.attachments.service.model.servicehandler.AttachmentReadEventContext; +import com.sap.cds.ql.Select; +import com.sap.cds.ql.StructuredType; +import com.sap.cds.services.persistence.PersistenceService; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.concurrent.TimeUnit; +import org.awaitility.Awaitility; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultMatcher; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles(Profiles.TEST_HANDLER_ENABLED) +class SingleAttachmentNonDraftTest { + + @Autowired private TestPluginAttachmentsServiceHandler serviceHandler; + @Autowired private MockHttpRequestHelper requestHelper; + @Autowired private PersistenceService persistenceService; + @Autowired private TableDataDeleter dataDeleter; + @Autowired private TestPersistenceHandler testPersistenceHandler; + @Autowired private MockMvc mvc; + + @AfterEach + void teardown() { + dataDeleter.deleteData(Roots_.CDS_NAME); + serviceHandler.clearEventContext(); + serviceHandler.clearDocuments(); + requestHelper.resetHelper(); + testPersistenceHandler.reset(); + } + + @Test + void createRootWithoutInlineAttachmentWorks() throws Exception { + var root = buildRootWithoutContent(); + postServiceRoot(root); + + var selectedRoot = selectStoredRoot(); + assertThat(selectedRoot.getId()).isNotEmpty(); + assertThat(selectedRoot.getTitle()).isEqualTo(root.getTitle()); + assertThat(selectedRoot.getAvatarContent()).isNull(); + assertThat(selectedRoot.getAvatarContentId()).isNull(); + assertThat(selectedRoot.getAvatarFileName()).isNull(); + verifyNoAttachmentEventsCalled(); + } + + @Test + void putContentToInlineAttachmentOnRootWorks() throws Exception { + var root = buildRootWithoutContent(); + postServiceRoot(root); + var selectedRoot = selectStoredRoot(); + + var content = putInlineAttachmentContentOnRoot(selectedRoot.getId()); + var rootAfterPut = selectStoredRoot(); + + assertThat(rootAfterPut.getAvatarContentId()).isNotEmpty(); + assertThat(rootAfterPut.getAvatarStatus()).isNotEmpty(); + verifySingleCreateEvent(rootAfterPut.getAvatarContentId(), content); + } + + @Test + void readInlineAttachmentContentOnRootReturnsContent() throws Exception { + var root = buildRootWithoutContent(); + postServiceRoot(root); + var selectedRoot = selectStoredRoot(); + + var content = putInlineAttachmentContentOnRoot(selectedRoot.getId()); + serviceHandler.clearEventContext(); + var rootAfterPut = selectStoredRoot(); + + var url = buildRootUrl(rootAfterPut.getId()) + "/avatar_content"; + var response = requestHelper.executeGet(url); + + assertThat(response.getResponse().getContentAsString()).isEqualTo(content); + verifySingleReadEvent(rootAfterPut.getAvatarContentId()); + } + + @Test + void readInlineAttachmentContentReturnsCorrectContentTypeHeader() throws Exception { + var root = buildRootWithoutContent(); + postServiceRoot(root); + var selectedRoot = selectStoredRoot(); + + var content = putInlineAttachmentContentOnRoot(selectedRoot.getId()); + serviceHandler.clearEventContext(); + var rootAfterPut = selectStoredRoot(); + + var url = buildRootUrl(rootAfterPut.getId()) + "/avatar_content"; + var response = requestHelper.executeGet(url); + + assertThat(response.getResponse().getContentAsString()).isEqualTo(content); + assertThat(response.getResponse().getContentType()).startsWith("application/octet-stream"); + } + + @Test + void readInlineAttachmentContentReturnsContentDispositionHeader() throws Exception { + var root = buildRootWithoutContent(); + postServiceRoot(root); + var selectedRoot = selectStoredRoot(); + + putInlineAttachmentContentOnRoot(selectedRoot.getId()); + serviceHandler.clearEventContext(); + var rootAfterPut = selectStoredRoot(); + + var url = buildRootUrl(rootAfterPut.getId()) + "/avatar_content"; + var response = requestHelper.executeGet(url); + + var contentDisposition = response.getResponse().getHeader("Content-Disposition"); + assertThat(contentDisposition).isNotNull(); + assertThat(contentDisposition).startsWith("inline"); + } + + @Test + void readInlineAttachmentContentReturnsFilenameInContentDisposition() throws Exception { + var root = buildRootWithoutContent(); + postServiceRoot(root); + var selectedRoot = selectStoredRoot(); + + putInlineAttachmentContentOnRoot(selectedRoot.getId()); + serviceHandler.clearEventContext(); + requestHelper.resetHelper(); // Reset after PUT to use JSON for PATCH + var rootAfterPut = selectStoredRoot(); + + var patchUrl = buildRootUrl(rootAfterPut.getId()); + requestHelper.executePatchWithODataResponseAndAssertStatusOk( + patchUrl, "{\"avatar_fileName\": \"test-file.bin\"}"); + + var url = buildRootUrl(rootAfterPut.getId()) + "/avatar_content"; + var response = requestHelper.executeGet(url); + + var contentDisposition = response.getResponse().getHeader("Content-Disposition"); + assertThat(contentDisposition).isNotNull(); + assertThat(contentDisposition).contains("filename=\"test-file.bin\""); + } + + @Test + void readInlineAttachmentContentWithCustomMimeTypeReturnsCorrectContentType() throws Exception { + var root = buildRootWithoutContent(); + postServiceRoot(root); + var selectedRoot = selectStoredRoot(); + + var url = buildRootUrl(selectedRoot.getId()) + "/avatar_content"; + requestHelper.setContentType(MediaType.IMAGE_PNG); + requestHelper.executePutWithMatcher( + url, "fake-image-content".getBytes(StandardCharsets.UTF_8), status().isNoContent()); + requestHelper.resetHelper(); + + serviceHandler.clearEventContext(); + var rootAfterPut = selectStoredRoot(); + + var readUrl = buildRootUrl(rootAfterPut.getId()) + "/avatar_content"; + var response = requestHelper.executeGet(readUrl); + + assertThat(response.getResponse().getContentType()).startsWith("image/png"); + } + + @Test + void selectInlineAttachmentIncludesMediaContentTypeAnnotation() throws Exception { + var root = buildRootWithoutContent(); + postServiceRoot(root); + var selectedRoot = selectStoredRoot(); + + var url = buildRootUrl(selectedRoot.getId()) + "/avatar_content"; + requestHelper.setContentType(MediaType.TEXT_PLAIN); + requestHelper.executePutWithMatcher( + url, "test-content".getBytes(StandardCharsets.UTF_8), status().isNoContent()); + requestHelper.resetHelper(); + + serviceHandler.clearEventContext(); + + var selectUrl = + MockHttpRequestHelper.ODATA_BASE_URL + + "TestService/Roots(" + + selectedRoot.getId() + + ")?$select=avatar_content,avatar_mimeType"; + var response = + requestHelper.executeGetWithSingleODataResponseAndAssertStatus(selectUrl, HttpStatus.OK); + + assertThat(response).contains("avatar_content@mediaContentType"); + assertThat(response).contains("text/plain"); + } + + @Test + void deleteInlineAttachmentContentOnRootClearsContent() throws Exception { + var root = buildRootWithoutContent(); + postServiceRoot(root); + var selectedRoot = selectStoredRoot(); + + putInlineAttachmentContentOnRoot(selectedRoot.getId()); + serviceHandler.clearEventContext(); + var rootAfterPut = selectStoredRoot(); + var contentIdBeforeDelete = rootAfterPut.getAvatarContentId(); + + var url = buildRootUrl(rootAfterPut.getId()) + "/avatar_content"; + requestHelper.executeDelete(url); + + var rootAfterDelete = selectStoredRoot(); + assertThat(rootAfterDelete.getAvatarContentId()).isNull(); + assertThat(rootAfterDelete.getAvatarContent()).isNull(); + verifySingleDeletionEvent(contentIdBeforeDelete); + } + + @Test + void updateInlineAttachmentContentOnRootWorks() throws Exception { + var root = buildRootWithoutContent(); + postServiceRoot(root); + var selectedRoot = selectStoredRoot(); + + putInlineAttachmentContentOnRoot(selectedRoot.getId()); + serviceHandler.clearEventContext(); + var rootAfterFirstPut = selectStoredRoot(); + var firstContentId = rootAfterFirstPut.getAvatarContentId(); + + var newContent = putInlineAttachmentContentOnRoot(rootAfterFirstPut.getId(), "newContent"); + var rootAfterSecondPut = selectStoredRoot(); + + assertThat(rootAfterSecondPut.getAvatarContentId()).isNotEmpty(); + assertThat(rootAfterSecondPut.getAvatarContentId()).isNotEqualTo(firstContentId); + verifySingleCreateAndDeleteEvent( + rootAfterSecondPut.getAvatarContentId(), firstContentId, newContent); + } + + @Test + void deleteRootDeletesInlineAttachmentContent() throws Exception { + var root = buildRootWithoutContent(); + postServiceRoot(root); + var selectedRoot = selectStoredRoot(); + + putInlineAttachmentContentOnRoot(selectedRoot.getId()); + serviceHandler.clearEventContext(); + var rootAfterPut = selectStoredRoot(); + var contentId = rootAfterPut.getAvatarContentId(); + + var url = buildRootUrl(rootAfterPut.getId()); + requestHelper.executeDeleteWithMatcher(url, status().isNoContent()); + + verifySingleDeletionEvent(contentId); + } + + @Test + void inlineAttachmentReadViaExpandHasNoFilledContent() throws Exception { + var root = buildRootWithoutContent(); + postServiceRoot(root); + var selectedRoot = selectStoredRoot(); + putInlineAttachmentContentOnRoot(selectedRoot.getId()); + serviceHandler.clearEventContext(); + + var url = MockHttpRequestHelper.ODATA_BASE_URL + "TestService/Roots?$select=ID,avatar_content"; + var response = + requestHelper.executeGetWithSingleODataResponseAndAssertStatus( + url, Roots.class, HttpStatus.OK); + + assertThat(response.getAvatarContent()).isNull(); + verifyNoAttachmentEventsCalled(); + } + + @Test + void createRootWithItemWithoutInlineAttachmentWorks() throws Exception { + var root = buildRootWithItem(); + postServiceRoot(root); + + var selectedRoot = selectStoredRootWithItems(); + assertThat(selectedRoot.getItems()).hasSize(1); + var item = selectedRoot.getItems().get(0); + assertThat(item.getIconContent()).isNull(); + assertThat(item.getIconContentId()).isNull(); + verifyNoAttachmentEventsCalled(); + } + + @Test + void putContentToInlineAttachmentOnItemWorks() throws Exception { + var root = buildRootWithItem(); + postServiceRoot(root); + var selectedRoot = selectStoredRootWithItems(); + var item = selectedRoot.getItems().get(0); + + var content = putInlineAttachmentContentOnItem(selectedRoot.getId(), item.getId()); + var rootAfterPut = selectStoredRootWithItems(); + var itemAfterPut = rootAfterPut.getItems().get(0); + + assertThat(itemAfterPut.getIconContentId()).isNotEmpty(); + assertThat(itemAfterPut.getIconStatus()).isNotEmpty(); + verifySingleCreateEvent(itemAfterPut.getIconContentId(), content); + } + + @Test + void readInlineAttachmentContentOnItemReturnsContent() throws Exception { + var root = buildRootWithItem(); + postServiceRoot(root); + var selectedRoot = selectStoredRootWithItems(); + var item = selectedRoot.getItems().get(0); + + var content = putInlineAttachmentContentOnItem(selectedRoot.getId(), item.getId()); + serviceHandler.clearEventContext(); + var rootAfterPut = selectStoredRootWithItems(); + var itemAfterPut = rootAfterPut.getItems().get(0); + + var url = buildItemUrl(selectedRoot.getId(), item.getId()) + "/icon_content"; + var response = requestHelper.executeGet(url); + + assertThat(response.getResponse().getContentAsString()).isEqualTo(content); + verifySingleReadEvent(itemAfterPut.getIconContentId()); + } + + @Test + void deleteInlineAttachmentContentOnItemClearsContent() throws Exception { + var root = buildRootWithItem(); + postServiceRoot(root); + var selectedRoot = selectStoredRootWithItems(); + var item = selectedRoot.getItems().get(0); + + putInlineAttachmentContentOnItem(selectedRoot.getId(), item.getId()); + serviceHandler.clearEventContext(); + var rootAfterPut = selectStoredRootWithItems(); + var itemAfterPut = rootAfterPut.getItems().get(0); + var contentIdBeforeDelete = itemAfterPut.getIconContentId(); + + var url = buildItemUrl(selectedRoot.getId(), item.getId()) + "/icon_content"; + requestHelper.executeDelete(url); + + var rootAfterDelete = selectStoredRootWithItems(); + var itemAfterDelete = rootAfterDelete.getItems().get(0); + assertThat(itemAfterDelete.getIconContentId()).isNull(); + assertThat(itemAfterDelete.getIconContent()).isNull(); + verifySingleDeletionEvent(contentIdBeforeDelete); + } + + @Test + void updateInlineAttachmentContentOnItemWorks() throws Exception { + var root = buildRootWithItem(); + postServiceRoot(root); + var selectedRoot = selectStoredRootWithItems(); + var item = selectedRoot.getItems().get(0); + + putInlineAttachmentContentOnItem(selectedRoot.getId(), item.getId()); + serviceHandler.clearEventContext(); + var rootAfterFirstPut = selectStoredRootWithItems(); + var itemAfterFirstPut = rootAfterFirstPut.getItems().get(0); + var firstContentId = itemAfterFirstPut.getIconContentId(); + + var newContent = + putInlineAttachmentContentOnItem( + rootAfterFirstPut.getId(), itemAfterFirstPut.getId(), "newContent"); + var rootAfterSecondPut = selectStoredRootWithItems(); + var itemAfterSecondPut = rootAfterSecondPut.getItems().get(0); + + assertThat(itemAfterSecondPut.getIconContentId()).isNotEmpty(); + assertThat(itemAfterSecondPut.getIconContentId()).isNotEqualTo(firstContentId); + verifySingleCreateAndDeleteEvent( + itemAfterSecondPut.getIconContentId(), firstContentId, newContent); + } + + @Test + void deleteItemDeletesInlineAttachmentContent() throws Exception { + var root = buildRootWithItem(); + postServiceRoot(root); + var selectedRoot = selectStoredRootWithItems(); + var item = selectedRoot.getItems().get(0); + + putInlineAttachmentContentOnItem(selectedRoot.getId(), item.getId()); + serviceHandler.clearEventContext(); + var rootAfterPut = selectStoredRootWithItems(); + var itemAfterPut = rootAfterPut.getItems().get(0); + var contentId = itemAfterPut.getIconContentId(); + + var url = buildItemUrl(selectedRoot.getId(), item.getId()); + requestHelper.executeDeleteWithMatcher(url, status().isNoContent()); + + verifySingleDeletionEvent(contentId); + } + + @Test + void deleteRootDeletesInlineAttachmentOnItemContent() throws Exception { + var root = buildRootWithItem(); + postServiceRoot(root); + var selectedRoot = selectStoredRootWithItems(); + var item = selectedRoot.getItems().get(0); + + putInlineAttachmentContentOnItem(selectedRoot.getId(), item.getId()); + serviceHandler.clearEventContext(); + var rootAfterPut = selectStoredRootWithItems(); + var itemAfterPut = rootAfterPut.getItems().get(0); + var contentId = itemAfterPut.getIconContentId(); + + var url = buildRootUrl(rootAfterPut.getId()); + requestHelper.executeDeleteWithMatcher(url, status().isNoContent()); + + verifySingleDeletionEvent(contentId); + } + + @Test + void deleteRootDeletesBothRootAndItemInlineAttachments() throws Exception { + var root = buildRootWithItem(); + postServiceRoot(root); + var selectedRoot = selectStoredRootWithItems(); + var item = selectedRoot.getItems().get(0); + + putInlineAttachmentContentOnRoot(selectedRoot.getId()); + putInlineAttachmentContentOnItem(selectedRoot.getId(), item.getId()); + serviceHandler.clearEventContext(); + var rootAfterPut = selectStoredRootWithItems(); + var itemAfterPut = rootAfterPut.getItems().get(0); + var rootContentId = rootAfterPut.getAvatarContentId(); + var itemContentId = itemAfterPut.getIconContentId(); + + var url = buildRootUrl(rootAfterPut.getId()); + requestHelper.executeDeleteWithMatcher(url, status().isNoContent()); + + verifyTwoDeletionEvents(rootContentId, itemContentId); + } + + @Test + void twoInlineAttachmentsOnSameEntityDoNotCollide() throws Exception { + var root = buildRootWithoutContent(); + postServiceRoot(root); + var selectedRoot = selectStoredRoot(); + + putInlineAttachmentContentOnRoot(selectedRoot.getId(), "avatarData"); + serviceHandler.clearEventContext(); + var coverImageContent = putCoverImageContentOnRoot(selectedRoot.getId(), "coverImageData"); + + var rootAfterPut = selectStoredRoot(); + + assertThat(rootAfterPut.getAvatarContentId()).isNotEmpty(); + assertThat(rootAfterPut.getCoverImageContentId()).isNotEmpty(); + assertThat(rootAfterPut.getAvatarContentId()) + .isNotEqualTo(rootAfterPut.getCoverImageContentId()); + + verifySingleCreateEvent(rootAfterPut.getCoverImageContentId(), coverImageContent); + } + + @Test + void readingOneInlineAttachmentDoesNotAffectOther() throws Exception { + var root = buildRootWithoutContent(); + postServiceRoot(root); + var selectedRoot = selectStoredRoot(); + + var avatarContent = putInlineAttachmentContentOnRoot(selectedRoot.getId(), "avatarData"); + var coverImageContent = putCoverImageContentOnRoot(selectedRoot.getId(), "coverImageData"); + serviceHandler.clearEventContext(); + + var rootAfterPut = selectStoredRoot(); + + var avatarUrl = buildRootUrl(rootAfterPut.getId()) + "/avatar_content"; + var avatarResponse = requestHelper.executeGet(avatarUrl); + assertThat(avatarResponse.getResponse().getContentAsString()).isEqualTo(avatarContent); + + verifySingleReadEvent(rootAfterPut.getAvatarContentId()); + serviceHandler.clearEventContext(); + + var coverImageUrl = buildRootUrl(rootAfterPut.getId()) + "/coverImage_content"; + var coverImageResponse = requestHelper.executeGet(coverImageUrl); + assertThat(coverImageResponse.getResponse().getContentAsString()).isEqualTo(coverImageContent); + + verifySingleReadEvent(rootAfterPut.getCoverImageContentId()); + } + + @Test + void deletingOneInlineAttachmentDoesNotAffectOther() throws Exception { + var root = buildRootWithoutContent(); + postServiceRoot(root); + var selectedRoot = selectStoredRoot(); + + putInlineAttachmentContentOnRoot(selectedRoot.getId(), "avatarData"); + putCoverImageContentOnRoot(selectedRoot.getId(), "coverImageData"); + serviceHandler.clearEventContext(); + + var rootAfterPut = selectStoredRoot(); + var avatarContentId = rootAfterPut.getAvatarContentId(); + var coverImageContentId = rootAfterPut.getCoverImageContentId(); + + var avatarUrl = buildRootUrl(rootAfterPut.getId()) + "/avatar_content"; + requestHelper.executeDelete(avatarUrl); + + var rootAfterDelete = selectStoredRoot(); + + assertThat(rootAfterDelete.getAvatarContentId()).isNull(); + assertThat(rootAfterDelete.getAvatarContent()).isNull(); + + assertThat(rootAfterDelete.getCoverImageContentId()).isEqualTo(coverImageContentId); + + verifySingleDeletionEvent(avatarContentId); + } + + @Test + void updatingOneInlineAttachmentDoesNotAffectOther() throws Exception { + var root = buildRootWithoutContent(); + postServiceRoot(root); + var selectedRoot = selectStoredRoot(); + + putInlineAttachmentContentOnRoot(selectedRoot.getId(), "avatarData"); + putCoverImageContentOnRoot(selectedRoot.getId(), "coverImageData"); + serviceHandler.clearEventContext(); + + var rootAfterFirstPut = selectStoredRoot(); + var originalAvatarContentId = rootAfterFirstPut.getAvatarContentId(); + var originalCoverImageContentId = rootAfterFirstPut.getCoverImageContentId(); + + var newAvatarContent = + putInlineAttachmentContentOnRoot(rootAfterFirstPut.getId(), "newAvatarData"); + + var rootAfterUpdate = selectStoredRoot(); + + assertThat(rootAfterUpdate.getAvatarContentId()).isNotEmpty(); + assertThat(rootAfterUpdate.getAvatarContentId()).isNotEqualTo(originalAvatarContentId); + + assertThat(rootAfterUpdate.getCoverImageContentId()).isEqualTo(originalCoverImageContentId); + + verifySingleCreateAndDeleteEvent( + rootAfterUpdate.getAvatarContentId(), originalAvatarContentId, newAvatarContent); + } + + @Test + void deleteRootDeletesBothInlineAttachments() throws Exception { + var root = buildRootWithoutContent(); + postServiceRoot(root); + var selectedRoot = selectStoredRoot(); + + putInlineAttachmentContentOnRoot(selectedRoot.getId(), "avatarData"); + putCoverImageContentOnRoot(selectedRoot.getId(), "coverImageData"); + serviceHandler.clearEventContext(); + + var rootAfterPut = selectStoredRoot(); + var avatarContentId = rootAfterPut.getAvatarContentId(); + var coverImageContentId = rootAfterPut.getCoverImageContentId(); + + var url = buildRootUrl(rootAfterPut.getId()); + requestHelper.executeDeleteWithMatcher(url, status().isNoContent()); + + verifyTwoDeletionEvents(avatarContentId, coverImageContentId); + } + + @Test + void doubleDeleteInlineAttachmentContentHandledCorrectly() throws Exception { + var root = buildRootWithoutContent(); + postServiceRoot(root); + var selectedRoot = selectStoredRoot(); + + putInlineAttachmentContentOnRoot(selectedRoot.getId()); + serviceHandler.clearEventContext(); + var rootAfterPut = selectStoredRoot(); + var contentId = rootAfterPut.getAvatarContentId(); + + var url = buildRootUrl(rootAfterPut.getId()) + "/avatar_content"; + requestHelper.executeDelete(url); + verifySingleDeletionEvent(contentId); + serviceHandler.clearEventContext(); + + var secondDeleteResult = requestHelper.executeDelete(url); + assertThat(secondDeleteResult.getResponse().getStatus()) + .isIn(HttpStatus.NO_CONTENT.value(), HttpStatus.OK.value()); + verifyNoAttachmentEventsCalled(); + } + + @ParameterizedTest + @CsvSource({"avatar_status,INFECTED", "avatar_contentId,TEST"}) + void readOnlyFieldsCannotBeUpdatedViaPatchOnRoot(String field, String value) throws Exception { + var root = buildRootWithoutContent(); + postServiceRoot(root); + var selectedRoot = selectStoredRoot(); + + putInlineAttachmentContentOnRoot(selectedRoot.getId()); + serviceHandler.clearEventContext(); + requestHelper.resetHelper(); + + var url = buildRootUrl(selectedRoot.getId()); + requestHelper.executePatchWithODataResponseAndAssertStatus( + url, "{\"" + field + "\":\"" + value + "\"}", HttpStatus.OK); + + var rootAfterPatch = selectStoredRoot(); + assertThat(rootAfterPatch.get(field)).isNotNull().isNotEqualTo(value); + } + + @Test + void errorInTransactionAfterCreateRollsBackContent() throws Exception { + var root = buildRootWithoutContent(); + postServiceRoot(root); + var selectedRoot = selectStoredRoot(); + + testPersistenceHandler.setThrowExceptionOnUpdate(true); + putInlineAttachmentContentOnRoot( + selectedRoot.getId(), "failContent", status().is5xxServerError()); + + var rootAfterError = selectStoredRoot(); + assertThat(rootAfterError.getAvatarContentId()).isNull(); + assertThat(rootAfterError.getAvatarContent()).isNull(); + } + + @Test + void uploadWithContentDispositionHeaderExtractsFileName() throws Exception { + var root = buildRootWithoutContent(); + postServiceRoot(root); + var selectedRoot = selectStoredRoot(); + + var url = buildRootUrl(selectedRoot.getId()) + "/avatar_content"; + mvc.perform( + MockMvcRequestBuilders.put(url) + .contentType(MediaType.APPLICATION_OCTET_STREAM) + .header("If-Match", "*") + .header("Content-Disposition", "attachment; filename=\"uploaded-avatar.png\"") + .content("avatar-data".getBytes(StandardCharsets.UTF_8))) + .andExpect(status().isNoContent()); + + var rootAfterPut = selectStoredRoot(); + assertThat(rootAfterPut.getAvatarFileName()).isEqualTo("uploaded-avatar.png"); + assertThat(rootAfterPut.getAvatarContentId()).isNotEmpty(); + } + + @Test + void uploadWithSlugHeaderExtractsFileName() throws Exception { + var root = buildRootWithoutContent(); + postServiceRoot(root); + var selectedRoot = selectStoredRoot(); + + var url = buildRootUrl(selectedRoot.getId()) + "/avatar_content"; + mvc.perform( + MockMvcRequestBuilders.put(url) + .contentType(MediaType.APPLICATION_OCTET_STREAM) + .header("If-Match", "*") + .header("slug", "uploaded-slug-file.txt") + .content("slug-data".getBytes(StandardCharsets.UTF_8))) + .andExpect(status().isNoContent()); + + var rootAfterPut = selectStoredRoot(); + assertThat(rootAfterPut.getAvatarFileName()).isEqualTo("uploaded-slug-file.txt"); + assertThat(rootAfterPut.getAvatarContentId()).isNotEmpty(); + } + + @Test + void uploadWithSpecificContentTypeStoresMimeType() throws Exception { + var root = buildRootWithoutContent(); + postServiceRoot(root); + var selectedRoot = selectStoredRoot(); + + var url = buildRootUrl(selectedRoot.getId()) + "/avatar_content"; + requestHelper.setContentType(MediaType.IMAGE_JPEG); + requestHelper.executePutWithMatcher( + url, "jpeg-data".getBytes(StandardCharsets.UTF_8), status().isNoContent()); + + var rootAfterPut = selectStoredRoot(); + assertThat(rootAfterPut.getAvatarMimeType()).startsWith("image/jpeg"); + assertThat(rootAfterPut.getAvatarContentId()).isNotEmpty(); + } + + @Test + void malwareScanStatusIsCleanAfterUpload() throws Exception { + var root = buildRootWithoutContent(); + postServiceRoot(root); + var selectedRoot = selectStoredRoot(); + + putInlineAttachmentContentOnRoot(selectedRoot.getId()); + + var rootAfterPut = selectStoredRoot(); + assertThat(rootAfterPut.getAvatarStatus()).isEqualTo("Clean"); + assertThat(rootAfterPut.getAvatarScannedAt()).isNotNull(); + } + + @Test + void putOversizedContentToCoverImageReturnsError() throws Exception { + var root = buildRootWithoutContent(); + postServiceRoot(root); + var selectedRoot = selectStoredRoot(); + + var url = buildRootUrl(selectedRoot.getId()) + "/coverImage_content"; + byte[] oversizedContent = new byte[6 * 1024 * 1024]; // 6MB > 5MB limit + requestHelper.setContentType(MediaType.APPLICATION_OCTET_STREAM); + requestHelper.executePutWithMatcher(url, oversizedContent, status().is4xxClientError()); + } + + private Roots buildRootWithoutContent() { + var root = Roots.create(); + root.setTitle("root with inline attachment"); + return root; + } + + private Roots buildRootWithItem() { + var root = Roots.create(); + root.setTitle("root with item"); + var items = new ArrayList(); + var item = Items.create(); + item.setTitle("item with inline attachment"); + items.add(item); + root.setItems(items); + return root; + } + + private void postServiceRoot(Roots root) throws Exception { + var url = MockHttpRequestHelper.ODATA_BASE_URL + "TestService/Roots"; + requestHelper.executePostWithMatcher(url, root.toJson(), status().isCreated()); + } + + private Roots selectStoredRoot() { + var select = Select.from(Roots_.class).columns(StructuredType::_all); + return persistenceService.run(select).single(Roots.class); + } + + private Roots selectStoredRootWithItems() { + var select = + Select.from(Roots_.class) + .columns(StructuredType::_all, root -> root.items().expand(StructuredType::_all)); + return persistenceService.run(select).single(Roots.class); + } + + private String buildRootUrl(String rootId) { + return MockHttpRequestHelper.ODATA_BASE_URL + "TestService/Roots(" + rootId + ")"; + } + + private String buildItemUrl(String rootId, String itemId) { + return MockHttpRequestHelper.ODATA_BASE_URL + + "TestService/Roots(" + + rootId + + ")/items(" + + itemId + + ")"; + } + + private String putInlineAttachmentContentOnRoot(String rootId) throws Exception { + return putInlineAttachmentContentOnRoot(rootId, "avatarContent"); + } + + private String putInlineAttachmentContentOnRoot(String rootId, String content) throws Exception { + return putInlineAttachmentContentOnRoot(rootId, content, status().isNoContent()); + } + + private String putInlineAttachmentContentOnRoot( + String rootId, String content, ResultMatcher matcher) throws Exception { + var url = buildRootUrl(rootId) + "/avatar_content"; + requestHelper.setContentType(MediaType.APPLICATION_OCTET_STREAM); + requestHelper.executePutWithMatcher(url, content.getBytes(StandardCharsets.UTF_8), matcher); + return content; + } + + private String putCoverImageContentOnRoot(String rootId, String content) throws Exception { + var url = buildRootUrl(rootId) + "/coverImage_content"; + requestHelper.setContentType(MediaType.APPLICATION_OCTET_STREAM); + requestHelper.executePutWithMatcher( + url, content.getBytes(StandardCharsets.UTF_8), status().isNoContent()); + return content; + } + + private String putInlineAttachmentContentOnItem(String rootId, String itemId) throws Exception { + return putInlineAttachmentContentOnItem(rootId, itemId, "iconContent"); + } + + private String putInlineAttachmentContentOnItem(String rootId, String itemId, String content) + throws Exception { + var url = buildItemUrl(rootId, itemId) + "/icon_content"; + requestHelper.setContentType(MediaType.APPLICATION_OCTET_STREAM); + requestHelper.executePutWithMatcher( + url, content.getBytes(StandardCharsets.UTF_8), status().isNoContent()); + return content; + } + + private void verifySingleCreateEvent(String contentId, String content) { + verifyEventContextEmptyForEvent( + AttachmentService.EVENT_READ_ATTACHMENT, + AttachmentService.EVENT_MARK_ATTACHMENT_AS_DELETED); + var createEvent = + serviceHandler.getEventContextForEvent(AttachmentService.EVENT_CREATE_ATTACHMENT); + assertThat(createEvent) + .hasSize(1) + .first() + .satisfies( + event -> { + assertThat(event.context()).isInstanceOf(AttachmentCreateEventContext.class); + var createContext = (AttachmentCreateEventContext) event.context(); + assertThat(createContext.getContentId()).isEqualTo(contentId); + assertThat(createContext.getData().getContent().readAllBytes()) + .isEqualTo(content.getBytes(StandardCharsets.UTF_8)); + }); + } + + private void verifySingleReadEvent(String contentId) { + verifyEventContextEmptyForEvent( + AttachmentService.EVENT_CREATE_ATTACHMENT, + AttachmentService.EVENT_MARK_ATTACHMENT_AS_DELETED); + var readContext = serviceHandler.getEventContext(); + assertThat(readContext) + .hasSize(1) + .first() + .satisfies( + event -> { + assertThat(event.event()).isEqualTo(AttachmentService.EVENT_READ_ATTACHMENT); + assertThat(((AttachmentReadEventContext) event.context()).getContentId()) + .isEqualTo(contentId); + }); + } + + private void verifySingleDeletionEvent(String contentId) { + waitTillExpectedHandlerMessageSize(1); + verifyEventContextEmptyForEvent( + AttachmentService.EVENT_CREATE_ATTACHMENT, AttachmentService.EVENT_READ_ATTACHMENT); + var deleteEvents = + serviceHandler.getEventContextForEvent(AttachmentService.EVENT_MARK_ATTACHMENT_AS_DELETED); + assertThat(deleteEvents) + .hasSize(1) + .first() + .satisfies( + event -> { + assertThat(event.context()).isInstanceOf(AttachmentMarkAsDeletedEventContext.class); + var deleteContext = (AttachmentMarkAsDeletedEventContext) event.context(); + assertThat(deleteContext.getContentId()).isEqualTo(contentId); + assertThat(deleteContext.getDeletionUserInfo().getName()).isEqualTo("anonymous"); + assertThat(deleteContext.getDeletionUserInfo().getIsSystemUser()).isFalse(); + }); + } + + private void verifySingleCreateAndDeleteEvent( + String newContentId, String deletedContentId, String content) { + waitTillExpectedHandlerMessageSize(2); + verifyEventContextEmptyForEvent(AttachmentService.EVENT_READ_ATTACHMENT); + var createEvents = + serviceHandler.getEventContextForEvent(AttachmentService.EVENT_CREATE_ATTACHMENT); + assertThat(createEvents).hasSize(1); + assertThat(createEvents) + .first() + .satisfies( + event -> { + var createContext = (AttachmentCreateEventContext) event.context(); + assertThat(createContext.getContentId()).isEqualTo(newContentId); + assertThat(createContext.getData().getContent().readAllBytes()) + .isEqualTo(content.getBytes(StandardCharsets.UTF_8)); + }); + + var deleteEvents = + serviceHandler.getEventContextForEvent(AttachmentService.EVENT_MARK_ATTACHMENT_AS_DELETED); + assertThat(deleteEvents) + .hasSize(1) + .first() + .satisfies( + event -> { + var deleteContext = (AttachmentMarkAsDeletedEventContext) event.context(); + assertThat(deleteContext.getContentId()).isEqualTo(deletedContentId); + }); + } + + private void verifyTwoDeletionEvents(String contentId1, String contentId2) { + waitTillExpectedHandlerMessageSize(2); + verifyEventContextEmptyForEvent( + AttachmentService.EVENT_CREATE_ATTACHMENT, AttachmentService.EVENT_READ_ATTACHMENT); + var deleteEvents = + serviceHandler.getEventContextForEvent(AttachmentService.EVENT_MARK_ATTACHMENT_AS_DELETED); + assertThat(deleteEvents).hasSize(2); + assertThat( + deleteEvents.stream() + .anyMatch(event -> verifyDeleteEventContentIdAndUserInfo(event, contentId1))) + .isTrue(); + assertThat( + deleteEvents.stream() + .anyMatch(event -> verifyDeleteEventContentIdAndUserInfo(event, contentId2))) + .isTrue(); + } + + private boolean verifyDeleteEventContentIdAndUserInfo( + EventContextHolder event, String contentId) { + var ctx = (AttachmentMarkAsDeletedEventContext) event.context(); + return ctx.getContentId().equals(contentId) + && "anonymous".equals(ctx.getDeletionUserInfo().getName()) + && Boolean.FALSE.equals(ctx.getDeletionUserInfo().getIsSystemUser()); + } + + private void verifyNoAttachmentEventsCalled() { + assertThat(serviceHandler.getEventContext()).isEmpty(); + } + + private void verifyEventContextEmptyForEvent(String... events) { + for (var event : events) { + assertThat(serviceHandler.getEventContextForEvent(event)).isEmpty(); + } + } + + private void waitTillExpectedHandlerMessageSize(int expectedSize) { + Awaitility.await() + .atMost(30, TimeUnit.SECONDS) + .pollDelay(1, TimeUnit.SECONDS) + .until(() -> serviceHandler.getEventContext().size() >= expectedSize); + } +} diff --git a/integration-tests/generic/test-service.cds b/integration-tests/generic/test-service.cds index ff68a31ff..163ac0f4d 100644 --- a/integration-tests/generic/test-service.cds +++ b/integration-tests/generic/test-service.cds @@ -16,6 +16,10 @@ annotate db.Roots.mimeValidatedAttachments with { content @(Core.AcceptableMediaTypes: ['application/pdf']); } +annotate db.Roots:coverImage with { + content @Validation.Maximum: '5MB'; +}; + service TestService { entity Roots as projection on db.Roots; entity AttachmentEntity as projection on db.AttachmentEntity; diff --git a/integration-tests/mtx-local/db/schema.cds b/integration-tests/mtx-local/db/schema.cds index 9edbfde50..eb2e45d95 100644 --- a/integration-tests/mtx-local/db/schema.cds +++ b/integration-tests/mtx-local/db/schema.cds @@ -1,7 +1,7 @@ namespace mt.test.data; -using { cuid } from '@sap/cds/common'; -using { sap.attachments.Attachments } from 'com.sap.cds/cds-feature-attachments'; +using {cuid} from '@sap/cds/common'; +using {sap.attachments.Attachments} from 'com.sap.cds/cds-feature-attachments'; entity Documents : cuid { title : String; diff --git a/samples/bookshop/README.md b/samples/bookshop/README.md index 7fe10868d..023c7d593 100644 --- a/samples/bookshop/README.md +++ b/samples/bookshop/README.md @@ -19,16 +19,19 @@ This sample demonstrates how to use the `cds-feature-attachments` plugin in a CA ## Getting Started 1. **Clone and navigate to the sample**: + ```bash cd samples/bookshop ``` 2. **Install dependencies**: + ```bash mvn clean compile ``` 3. **Run the application**: + ```bash mvn spring-boot:run ``` @@ -87,7 +90,7 @@ The `srv/attachments.cds` file extends the Books entity with attachments: ```cds using { sap.capire.bookshop as my } from '../db/schema'; -using { sap.attachments.Attachments } from 'com.sap.cds/cds-feature-attachments'; +using { Attachments } from 'com.sap.cds/cds-feature-attachments'; extend my.Books with { attachments: Composition of many Attachments; diff --git a/samples/bookshop/pom.xml b/samples/bookshop/pom.xml index a49e3c384..069719e4a 100644 --- a/samples/bookshop/pom.xml +++ b/samples/bookshop/pom.xml @@ -12,9 +12,10 @@ bookshop parent + 1.5.0 17 - 4.6.1 + 4.9.0 3.5.7 UTF-8 @@ -48,7 +49,7 @@ com.sap.cds cds-feature-attachments - 1.5.0 + ${cds.feature.attachments.version} diff --git a/samples/bookshop/srv/attachments.cds b/samples/bookshop/srv/attachments.cds index 0528bf1bc..917c43a87 100644 --- a/samples/bookshop/srv/attachments.cds +++ b/samples/bookshop/srv/attachments.cds @@ -1,5 +1,8 @@ using {sap.capire.bookshop as my} from '../db/schema'; -using {sap.attachments.Attachments} from 'com.sap.cds/cds-feature-attachments'; +using { + Attachments, + Attachment +} 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 @@ -23,6 +26,16 @@ annotate my.Books.mediaValidatedAttachments with { ]; } +// Extend Books entity with inline single-file attachments +extend my.Books with { + profileIcon : Attachment; + coverImage : Attachment; +} + +annotate my.Books : profileIcon with { + content @Validation.Maximum: '1MB' @Core.AcceptableMediaTypes: ['image/*']; +} + // Add UI component for attachments table to the Browse Books App using {CatalogService as service} from '../app/services'; @@ -36,14 +49,39 @@ annotate service.Books with @(UI.Facets: [{ // Adding the UI Component (a table) to the Administrator App using {AdminService as adminService} from '../app/services'; -annotate adminService.Books with @(UI.Facets: [{ - $Type : 'UI.ReferenceFacet', - ID : 'AttachmentsFacet', - Label : '{i18n>attachments}', - Target: 'attachments/@UI.LineItem' -}]); - +annotate adminService.Books with @(UI.Facets: [ + { + $Type : 'UI.ReferenceFacet', + ID : 'AttachmentsFacet', + Label : '{i18n>attachments}', + Target: 'attachments/@UI.LineItem' + }, + { + $Type : 'UI.ReferenceFacet', + Label : 'Profile Icon', + Target: '@UI.FieldGroup#ProfileIcon' + }, + { + $Type : 'UI.ReferenceFacet', + Label : 'Cover Image', + Target: '@UI.FieldGroup#CoverImage' + } +]); -service nonDraft { - entity Books as projection on my.Books; -} +annotate adminService.Books with @(UI: { + FieldGroup #ProfileIcon: {Data: [ + { + Value: profileIcon_content, + Label: 'Download' + }, + {Value: profileIcon_fileName}, + {Value: profileIcon_status}, + {Value: profileIcon_note} + ]}, + FieldGroup #CoverImage : {Data: [ + {Value: coverImage_content}, + {Value: coverImage_fileName}, + {Value: coverImage_status}, + {Value: coverImage_note} + ]} +});