diff --git a/.gitignore b/.gitignore index 9ca83a529..ca9f60e59 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ target/ .flattened-pom.xml node_modules +package-lock.json ## PMD .pmd diff --git a/integration-tests/.cdsrc.json b/integration-tests/.cdsrc.json index c2d556179..0b8f71263 100644 --- a/integration-tests/.cdsrc.json +++ b/integration-tests/.cdsrc.json @@ -7,6 +7,11 @@ }, "fiori": { "draft_messages": false + }, + "build": { + "tasks": [ + { "for": "java", "src": "generic" } + ] } } } \ No newline at end of file diff --git a/integration-tests/srv/pom.xml b/integration-tests/generic/pom.xml similarity index 92% rename from integration-tests/srv/pom.xml rename to integration-tests/generic/pom.xml index 6a5b6eada..d47623f06 100644 --- a/integration-tests/srv/pom.xml +++ b/integration-tests/generic/pom.xml @@ -8,10 +8,10 @@ ${revision} - cds-feature-attachments-integration-tests-srv + cds-feature-attachments-integration-tests-generic jar - Integration Tests - Service + Integration Tests - Generic com.sap.cds.feature.attachments.generated @@ -97,7 +97,7 @@ ${project.basedir}/.. build --for java - deploy --to h2 --dry > + deploy --to h2 --dry db generic > "${project.basedir}/src/main/resources/schema.sql" @@ -110,6 +110,7 @@ ${project.basedir}/.. + ${project.basedir}/src/main/resources/edmx/csn.json ${generation-package}.integration.test.cds4j diff --git a/integration-tests/srv/src/main/java/com/sap/cds/feature/attachments/integrationtests/Application.java b/integration-tests/generic/src/main/java/com/sap/cds/feature/attachments/integrationtests/Application.java similarity index 100% rename from integration-tests/srv/src/main/java/com/sap/cds/feature/attachments/integrationtests/Application.java rename to integration-tests/generic/src/main/java/com/sap/cds/feature/attachments/integrationtests/Application.java diff --git a/integration-tests/srv/src/main/java/com/sap/cds/feature/attachments/integrationtests/constants/Profiles.java b/integration-tests/generic/src/main/java/com/sap/cds/feature/attachments/integrationtests/constants/Profiles.java similarity index 100% rename from integration-tests/srv/src/main/java/com/sap/cds/feature/attachments/integrationtests/constants/Profiles.java rename to integration-tests/generic/src/main/java/com/sap/cds/feature/attachments/integrationtests/constants/Profiles.java diff --git a/integration-tests/srv/src/main/java/com/sap/cds/feature/attachments/integrationtests/testhandler/EventContextHolder.java b/integration-tests/generic/src/main/java/com/sap/cds/feature/attachments/integrationtests/testhandler/EventContextHolder.java similarity index 100% rename from integration-tests/srv/src/main/java/com/sap/cds/feature/attachments/integrationtests/testhandler/EventContextHolder.java rename to integration-tests/generic/src/main/java/com/sap/cds/feature/attachments/integrationtests/testhandler/EventContextHolder.java diff --git a/integration-tests/srv/src/main/java/com/sap/cds/feature/attachments/integrationtests/testhandler/TestPersistenceHandler.java b/integration-tests/generic/src/main/java/com/sap/cds/feature/attachments/integrationtests/testhandler/TestPersistenceHandler.java similarity index 100% rename from integration-tests/srv/src/main/java/com/sap/cds/feature/attachments/integrationtests/testhandler/TestPersistenceHandler.java rename to integration-tests/generic/src/main/java/com/sap/cds/feature/attachments/integrationtests/testhandler/TestPersistenceHandler.java diff --git a/integration-tests/srv/src/main/java/com/sap/cds/feature/attachments/integrationtests/testhandler/TestPluginAttachmentsServiceHandler.java b/integration-tests/generic/src/main/java/com/sap/cds/feature/attachments/integrationtests/testhandler/TestPluginAttachmentsServiceHandler.java similarity index 100% rename from integration-tests/srv/src/main/java/com/sap/cds/feature/attachments/integrationtests/testhandler/TestPluginAttachmentsServiceHandler.java rename to integration-tests/generic/src/main/java/com/sap/cds/feature/attachments/integrationtests/testhandler/TestPluginAttachmentsServiceHandler.java diff --git a/integration-tests/srv/src/main/resources/application.yaml b/integration-tests/generic/src/main/resources/application.yaml similarity index 100% rename from integration-tests/srv/src/main/resources/application.yaml rename to integration-tests/generic/src/main/resources/application.yaml diff --git a/integration-tests/srv/src/main/resources/banner.txt b/integration-tests/generic/src/main/resources/banner.txt similarity index 100% rename from integration-tests/srv/src/main/resources/banner.txt rename to integration-tests/generic/src/main/resources/banner.txt diff --git a/integration-tests/srv/src/main/resources/messages.properties b/integration-tests/generic/src/main/resources/messages.properties similarity index 100% rename from integration-tests/srv/src/main/resources/messages.properties rename to integration-tests/generic/src/main/resources/messages.properties diff --git a/integration-tests/srv/src/main/resources/spotbugs-exclusion-filter.xml b/integration-tests/generic/src/main/resources/spotbugs-exclusion-filter.xml similarity index 100% rename from integration-tests/srv/src/main/resources/spotbugs-exclusion-filter.xml rename to integration-tests/generic/src/main/resources/spotbugs-exclusion-filter.xml diff --git a/integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/common/JsonToCapMapperTestHelper.java b/integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/common/JsonToCapMapperTestHelper.java similarity index 100% rename from integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/common/JsonToCapMapperTestHelper.java rename to integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/common/JsonToCapMapperTestHelper.java diff --git a/integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/common/MalwareScanResultProvider.java b/integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/common/MalwareScanResultProvider.java similarity index 100% rename from integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/common/MalwareScanResultProvider.java rename to integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/common/MalwareScanResultProvider.java diff --git a/integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/common/MockHttpRequestHelper.java b/integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/common/MockHttpRequestHelper.java similarity index 100% rename from integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/common/MockHttpRequestHelper.java rename to integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/common/MockHttpRequestHelper.java diff --git a/integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/common/TableDataDeleter.java b/integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/common/TableDataDeleter.java similarity index 100% rename from integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/common/TableDataDeleter.java rename to integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/common/TableDataDeleter.java diff --git a/integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/draftservice/DraftOdataRequestValidationBase.java b/integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/draftservice/DraftOdataRequestValidationBase.java similarity index 100% rename from integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/draftservice/DraftOdataRequestValidationBase.java rename to integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/draftservice/DraftOdataRequestValidationBase.java diff --git a/integration-tests/srv/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 similarity index 100% rename from integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/draftservice/DraftOdataRequestValidationWithTestHandlerTest.java rename to integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/draftservice/DraftOdataRequestValidationWithTestHandlerTest.java diff --git a/integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/draftservice/DraftOdataRequestValidationWithoutTestHandlerAndMalwareScannerTest.java b/integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/draftservice/DraftOdataRequestValidationWithoutTestHandlerAndMalwareScannerTest.java similarity index 100% rename from integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/draftservice/DraftOdataRequestValidationWithoutTestHandlerAndMalwareScannerTest.java rename to integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/draftservice/DraftOdataRequestValidationWithoutTestHandlerAndMalwareScannerTest.java diff --git a/integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/draftservice/DraftOdataRequestValidationWithoutTestHandlerAndWithoutMalwareScannerTest.java b/integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/draftservice/DraftOdataRequestValidationWithoutTestHandlerAndWithoutMalwareScannerTest.java similarity index 100% rename from integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/draftservice/DraftOdataRequestValidationWithoutTestHandlerAndWithoutMalwareScannerTest.java rename to integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/draftservice/DraftOdataRequestValidationWithoutTestHandlerAndWithoutMalwareScannerTest.java diff --git a/integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/draftservice/MediaValidatedAttachmentsDraftTest.java b/integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/draftservice/MediaValidatedAttachmentsDraftTest.java similarity index 100% rename from integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/draftservice/MediaValidatedAttachmentsDraftTest.java rename to integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/draftservice/MediaValidatedAttachmentsDraftTest.java diff --git a/integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/draftservice/SizeLimitedAttachmentsSizeValidationDraftTest.java b/integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/draftservice/SizeLimitedAttachmentsSizeValidationDraftTest.java similarity index 100% rename from integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/draftservice/SizeLimitedAttachmentsSizeValidationDraftTest.java rename to integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/draftservice/SizeLimitedAttachmentsSizeValidationDraftTest.java diff --git a/integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/MediaValidatedAttachmentsNonDraftTest.java b/integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/MediaValidatedAttachmentsNonDraftTest.java similarity index 100% rename from integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/MediaValidatedAttachmentsNonDraftTest.java rename to integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/MediaValidatedAttachmentsNonDraftTest.java diff --git a/integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/OdataRequestValidationBase.java b/integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/OdataRequestValidationBase.java similarity index 100% rename from integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/OdataRequestValidationBase.java rename to integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/OdataRequestValidationBase.java diff --git a/integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/OdataRequestValidationWithTestHandlerTest.java b/integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/OdataRequestValidationWithTestHandlerTest.java similarity index 100% rename from integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/OdataRequestValidationWithTestHandlerTest.java rename to integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/OdataRequestValidationWithTestHandlerTest.java diff --git a/integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/OdataRequestValidationWithoutTestHandlerAndMalwareScannerTest.java b/integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/OdataRequestValidationWithoutTestHandlerAndMalwareScannerTest.java similarity index 100% rename from integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/OdataRequestValidationWithoutTestHandlerAndMalwareScannerTest.java rename to integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/OdataRequestValidationWithoutTestHandlerAndMalwareScannerTest.java diff --git a/integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/OdataRequestValidationWithoutTestHandlerAndWithoutMalwareScannerTest.java b/integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/OdataRequestValidationWithoutTestHandlerAndWithoutMalwareScannerTest.java similarity index 100% rename from integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/OdataRequestValidationWithoutTestHandlerAndWithoutMalwareScannerTest.java rename to integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/OdataRequestValidationWithoutTestHandlerAndWithoutMalwareScannerTest.java diff --git a/integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/SizeLimitedAttachmentValidationNonDraftTest.java b/integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/SizeLimitedAttachmentValidationNonDraftTest.java similarity index 100% rename from integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/SizeLimitedAttachmentValidationNonDraftTest.java rename to integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/SizeLimitedAttachmentValidationNonDraftTest.java diff --git a/integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/helper/AttachmentsBuilder.java b/integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/helper/AttachmentsBuilder.java similarity index 100% rename from integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/helper/AttachmentsBuilder.java rename to integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/helper/AttachmentsBuilder.java diff --git a/integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/helper/AttachmentsEntityBuilder.java b/integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/helper/AttachmentsEntityBuilder.java similarity index 100% rename from integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/helper/AttachmentsEntityBuilder.java rename to integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/helper/AttachmentsEntityBuilder.java diff --git a/integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/helper/ItemEntityBuilder.java b/integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/helper/ItemEntityBuilder.java similarity index 100% rename from integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/helper/ItemEntityBuilder.java rename to integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/helper/ItemEntityBuilder.java diff --git a/integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/helper/RootEntityBuilder.java b/integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/helper/RootEntityBuilder.java similarity index 100% rename from integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/helper/RootEntityBuilder.java rename to integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/helper/RootEntityBuilder.java diff --git a/integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/testhandler/TestPersistenceHandlerTest.java b/integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/testhandler/TestPersistenceHandlerTest.java similarity index 100% rename from integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/testhandler/TestPersistenceHandlerTest.java rename to integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/testhandler/TestPersistenceHandlerTest.java diff --git a/integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/testhandler/TestPluginAttachmentsServiceHandlerTest.java b/integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/testhandler/TestPluginAttachmentsServiceHandlerTest.java similarity index 97% rename from integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/testhandler/TestPluginAttachmentsServiceHandlerTest.java rename to integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/testhandler/TestPluginAttachmentsServiceHandlerTest.java index 2d835de70..dcb46fbff 100644 --- a/integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/testhandler/TestPluginAttachmentsServiceHandlerTest.java +++ b/integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/testhandler/TestPluginAttachmentsServiceHandlerTest.java @@ -6,7 +6,6 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.mockito.Mockito.*; import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.MediaData; import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.StatusCode; @@ -17,7 +16,6 @@ import com.sap.cds.feature.attachments.service.model.servicehandler.AttachmentRestoreEventContext; import java.io.ByteArrayInputStream; import java.io.IOException; -import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.time.Instant; import java.util.List; @@ -75,12 +73,10 @@ void dummyTestForDelete() { } @Test - void dummyTestForCreate() throws IOException { + void dummyTestForCreate() { var context = AttachmentCreateEventContext.create(); context.setData(MediaData.create()); - var stream = mock(InputStream.class); - when(stream.readAllBytes()).thenReturn("test".getBytes(StandardCharsets.UTF_8)); - context.getData().setContent(stream); + context.getData().setContent(new ByteArrayInputStream("test".getBytes(StandardCharsets.UTF_8))); assertDoesNotThrow(() -> cut.createAttachment(context)); } diff --git a/integration-tests/srv/src/test/resources/application.yaml b/integration-tests/generic/src/test/resources/application.yaml similarity index 100% rename from integration-tests/srv/src/test/resources/application.yaml rename to integration-tests/generic/src/test/resources/application.yaml diff --git a/integration-tests/srv/src/test/resources/logback-test.xml b/integration-tests/generic/src/test/resources/logback-test.xml similarity index 100% rename from integration-tests/srv/src/test/resources/logback-test.xml rename to integration-tests/generic/src/test/resources/logback-test.xml diff --git a/integration-tests/srv/src/test/resources/xsuaa-env.json b/integration-tests/generic/src/test/resources/xsuaa-env.json similarity index 100% rename from integration-tests/srv/src/test/resources/xsuaa-env.json rename to integration-tests/generic/src/test/resources/xsuaa-env.json diff --git a/integration-tests/srv/test-service.cds b/integration-tests/generic/test-service.cds similarity index 100% rename from integration-tests/srv/test-service.cds rename to integration-tests/generic/test-service.cds diff --git a/integration-tests/mtx-local/.cdsrc.json b/integration-tests/mtx-local/.cdsrc.json new file mode 100644 index 000000000..75b24c377 --- /dev/null +++ b/integration-tests/mtx-local/.cdsrc.json @@ -0,0 +1,14 @@ +{ + "profile": "with-mtx-sidecar", + "requires": { + "multitenancy": true, + "extensibility": true, + "toggles": true, + "db": { + "kind": "sqlite" + } + }, + "cdsc": { + "moduleLookupDirectories": ["node_modules/", "target/cds/"] + } +} diff --git a/integration-tests/mtx-local/.gitignore b/integration-tests/mtx-local/.gitignore new file mode 100644 index 000000000..41fd97059 --- /dev/null +++ b/integration-tests/mtx-local/.gitignore @@ -0,0 +1 @@ +integration-tests/mtx-local/package-lock.json diff --git a/integration-tests/mtx-local/db/index.cds b/integration-tests/mtx-local/db/index.cds new file mode 100644 index 000000000..3771e1112 --- /dev/null +++ b/integration-tests/mtx-local/db/index.cds @@ -0,0 +1 @@ +using from './schema.cds'; diff --git a/integration-tests/mtx-local/db/schema.cds b/integration-tests/mtx-local/db/schema.cds new file mode 100644 index 000000000..9edbfde50 --- /dev/null +++ b/integration-tests/mtx-local/db/schema.cds @@ -0,0 +1,9 @@ +namespace mt.test.data; + +using { cuid } from '@sap/cds/common'; +using { sap.attachments.Attachments } from 'com.sap.cds/cds-feature-attachments'; + +entity Documents : cuid { + title : String; + attachments : Composition of many Attachments; +} diff --git a/integration-tests/mtx-local/mtx/sidecar/package.json b/integration-tests/mtx-local/mtx/sidecar/package.json new file mode 100644 index 000000000..b2936eb9d --- /dev/null +++ b/integration-tests/mtx-local/mtx/sidecar/package.json @@ -0,0 +1,30 @@ +{ + "name": "mtx-local-sidecar", + "version": "0.0.0", + "dependencies": { + "@sap/cds": "^9", + "@sap/cds-mtxs": "^3", + "@sap/xssec": "^4", + "express": "^4" + }, + "devDependencies": { + "@cap-js/sqlite": "^2" + }, + "cds": { + "profile": "mtx-sidecar", + "[development]": { + "requires": { + "auth": "dummy" + }, + "db": { + "kind": "sqlite", + "credentials": { + "url": "../../db.sqlite" + } + } + } + }, + "scripts": { + "start": "cds-serve --profile development" + } +} diff --git a/integration-tests/mtx-local/package.json b/integration-tests/mtx-local/package.json new file mode 100644 index 000000000..ccad7b80d --- /dev/null +++ b/integration-tests/mtx-local/package.json @@ -0,0 +1,11 @@ +{ + "name": "mtx-local-integration-tests", + "version": "0.0.0", + "devDependencies": { + "@sap/cds-dk": "^9", + "@sap/cds-mtxs": "^3" + }, + "workspaces": [ + "mtx/sidecar" + ] +} diff --git a/integration-tests/mtx-local/srv/pom.xml b/integration-tests/mtx-local/srv/pom.xml new file mode 100644 index 000000000..330674cf2 --- /dev/null +++ b/integration-tests/mtx-local/srv/pom.xml @@ -0,0 +1,263 @@ + + + 4.0.0 + + + com.sap.cds.integration-tests + cds-feature-attachments-integration-tests-parent + ${revision} + ../../pom.xml + + + cds-feature-attachments-integration-tests-mtx-local + jar + + Integration Tests - MTX Local + + + com.sap.cds.feature.attachments.generated + ${project.basedir}/../mtx/sidecar + true + true + true + + + + + + com.sap.cds + cds-starter-spring-boot + + + + + com.sap.cds + cds-adapter-odata-v4 + runtime + + + + + com.sap.cds + cds-starter-cloudfoundry + + + + + org.xerial + sqlite-jdbc + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + org.springframework.security + spring-security-test + test + + + + + + + + org.apache.maven.plugins + maven-clean-plugin + + + + ${project.basedir}/.. + false + + *.db + *.sqlite* + + + + + + + sidecar-dbs-clean + + clean + + initialize + + + + + + + com.sap.cds + cds-maven-plugin + + + cds.clean + + clean + + + + + cds.install-node + + install-node + + + + + cds.resolve + + resolve + + + ${project.basedir}/.. + ${project.basedir}/.. + + + + + install-dependencies + + npm + + + ${project.basedir}/.. + + install + + + + + + cds.build + + cds + + + ${project.basedir}/.. + + build --for java + + + + + + cds.generate + + generate + + + ${project.basedir}/.. + ${generation-package}.mt.test.cds4j + + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + true + + + + + + org.apache.maven.plugins + maven-failsafe-plugin + + + execute-local-integration-tests + + integration-test + + integration-test + + + **/**/*Test.java + + + + + verify-local-integration-tests + + verify + + + + + + + + org.apache.maven.plugins + maven-resources-plugin + + + copy-cds-models-to-sidecar + + copy-resources + + pre-integration-test + + ${project.basedir}/../node_modules/com.sap.cds/cds-feature-attachments + + + ${project.basedir}/../target/cds/com.sap.cds/cds-feature-attachments + + + + + + + + + + org.codehaus.mojo + exec-maven-plugin + 3.5.0 + + + ${cds.node.directory}${path.separator}${env.PATH} + + ${skipTests} + + + + start-sidecar + + exec + + pre-integration-test + + ${cds.npm.executable} + ${sidecar.dir} + true + true + run start + + + + stop-sidecar + + exec + + post-integration-test + + sh + -c "lsof -ti :4005 | xargs kill 2>/dev/null || true" + + + + + + + + diff --git a/integration-tests/mtx-local/srv/service.cds b/integration-tests/mtx-local/srv/service.cds new file mode 100644 index 000000000..38b843210 --- /dev/null +++ b/integration-tests/mtx-local/srv/service.cds @@ -0,0 +1,5 @@ +using { mt.test.data as db } from '../db/index'; + +service MtTestService { + entity Documents as projection on db.Documents; +} diff --git a/integration-tests/mtx-local/srv/src/main/java/com/sap/cds/feature/attachments/integrationtests/mt/Application.java b/integration-tests/mtx-local/srv/src/main/java/com/sap/cds/feature/attachments/integrationtests/mt/Application.java new file mode 100644 index 000000000..fc98c7fb8 --- /dev/null +++ b/integration-tests/mtx-local/srv/src/main/java/com/sap/cds/feature/attachments/integrationtests/mt/Application.java @@ -0,0 +1,15 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. + */ +package com.sap.cds.feature.attachments.integrationtests.mt; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class Application { + + public static void main(String[] args) { + SpringApplication.run(Application.class, args); + } +} diff --git a/integration-tests/mtx-local/srv/src/main/java/com/sap/cds/feature/attachments/integrationtests/mt/handlers/system/SubscribeModelTenantsHandler.java b/integration-tests/mtx-local/srv/src/main/java/com/sap/cds/feature/attachments/integrationtests/mt/handlers/system/SubscribeModelTenantsHandler.java new file mode 100644 index 000000000..06b260c62 --- /dev/null +++ b/integration-tests/mtx-local/srv/src/main/java/com/sap/cds/feature/attachments/integrationtests/mt/handlers/system/SubscribeModelTenantsHandler.java @@ -0,0 +1,97 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. + */ +package com.sap.cds.feature.attachments.integrationtests.mt.handlers.system; + +import com.sap.cds.services.application.ApplicationLifecycleService; +import com.sap.cds.services.application.ApplicationPreparedEventContext; +import com.sap.cds.services.application.ApplicationStoppedEventContext; +import com.sap.cds.services.environment.CdsProperties; +import com.sap.cds.services.handler.EventHandler; +import com.sap.cds.services.handler.annotations.On; +import com.sap.cds.services.handler.annotations.ServiceName; +import com.sap.cds.services.mt.DeploymentService; +import com.sap.cds.services.runtime.CdsRuntime; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.stream.Collectors; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +@Component +@ServiceName(ApplicationLifecycleService.DEFAULT_NAME) +public class SubscribeModelTenantsHandler implements EventHandler { + + @Autowired private CdsRuntime runtime; + @Autowired private DeploymentService service; + + @On(event = ApplicationLifecycleService.EVENT_APPLICATION_PREPARED) + public void subscribeMockTenants(ApplicationPreparedEventContext context) { + var multiTenancy = runtime.getEnvironment().getCdsProperties().getMultiTenancy(); + + if (Boolean.FALSE.equals(multiTenancy.getMock().isEnabled())) { + return; + } + List tenants = readMockedTenants(); + if (tenants.isEmpty()) { + return; + } + if (!StringUtils.hasText(multiTenancy.getSidecar().getUrl())) { + return; + } + + tenants.forEach(this::subscribeTenant); + } + + @On(event = ApplicationLifecycleService.EVENT_APPLICATION_STOPPED) + public void unsubscribeMockTenants(ApplicationStoppedEventContext context) { + var multiTenancy = runtime.getEnvironment().getCdsProperties().getMultiTenancy(); + + if (Boolean.FALSE.equals(multiTenancy.getMock().isEnabled())) { + return; + } + List tenants = readMockedTenants(); + if (tenants.isEmpty()) { + return; + } + + tenants.forEach(this::unsubscribeTenant); + } + + private void subscribeTenant(String tenant) { + runtime + .requestContext() + .privilegedUser() + .run( + c -> { + service.subscribe( + tenant, + new HashMap<>(Collections.singletonMap("subscribedSubdomain", "mt-" + tenant))); + }); + } + + private void unsubscribeTenant(String tenant) { + runtime + .requestContext() + .privilegedUser() + .run( + c -> { + service.unsubscribe(tenant, Collections.emptyMap()); + }); + } + + private List readMockedTenants() { + return runtime + .getEnvironment() + .getCdsProperties() + .getSecurity() + .getMock() + .getTenants() + .values() + .stream() + .map(CdsProperties.Security.Mock.Tenant::getName) + .collect(Collectors.toList()); + } +} diff --git a/integration-tests/mtx-local/srv/src/main/resources/application.yaml b/integration-tests/mtx-local/srv/src/main/resources/application.yaml new file mode 100644 index 000000000..2bf2b72a3 --- /dev/null +++ b/integration-tests/mtx-local/srv/src/main/resources/application.yaml @@ -0,0 +1,23 @@ +cds: + multi-tenancy: + sidecar: + url: http://localhost:4005 + security: + mock: + users: + - name: user-in-tenant-1 + tenant: tenant-1 + - name: user-in-tenant-2 + tenant: tenant-2 + - name: user-in-tenant-3 + tenant: tenant-3 + +--- +spring: + config.activate.on-profile: local-with-tenants +cds: + security: + mock: + tenants: + - name: tenant-1 + - name: tenant-2 diff --git a/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/mt/MultiTenantAttachmentIsolationTest.java b/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/mt/MultiTenantAttachmentIsolationTest.java new file mode 100644 index 000000000..25d2d5339 --- /dev/null +++ b/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/mt/MultiTenantAttachmentIsolationTest.java @@ -0,0 +1,122 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. + */ +package com.sap.cds.feature.attachments.integrationtests.mt; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.UUID; +import org.junit.jupiter.api.Test; +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; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("local-with-tenants") +// TODO: Add tests that upload/download actual binary attachment content across tenants +// to verify storage-level isolation (not just entity-level isolation). +class MultiTenantAttachmentIsolationTest { + + private static final String DOCUMENTS_URL = "/odata/v4/MtTestService/Documents"; + + @Autowired MockMvc client; + @Autowired ObjectMapper objectMapper; + + @Test + void createDocumentInTenant1_notVisibleInTenant2() throws Exception { + String uniqueTitle = "Only-in-T1-" + UUID.randomUUID(); + + // Create a document in tenant-1 + client + .perform( + post(DOCUMENTS_URL) + .with(httpBasic("user-in-tenant-1", "")) + .contentType(MediaType.APPLICATION_JSON) + .content("{ \"title\": \"" + uniqueTitle + "\" }")) + .andExpect(status().isCreated()); + + // Read documents in tenant-2 — should NOT see the tenant-1 document + String response = + client + .perform(get(DOCUMENTS_URL).with(httpBasic("user-in-tenant-2", ""))) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(); + + JsonNode values = objectMapper.readTree(response).path("value"); + values.forEach(node -> assertThat(node.get("title").asText("")).isNotEqualTo(uniqueTitle)); + } + + @Test + void createDocumentsInBothTenants_eachSeeOnlyOwn() throws Exception { + String titleT1 = "Doc-T1-" + UUID.randomUUID(); + String titleT2 = "Doc-T2-" + UUID.randomUUID(); + + // Create in tenant-1 + client + .perform( + post(DOCUMENTS_URL) + .with(httpBasic("user-in-tenant-1", "")) + .contentType(MediaType.APPLICATION_JSON) + .content("{ \"title\": \"" + titleT1 + "\" }")) + .andExpect(status().isCreated()); + + // Create in tenant-2 + client + .perform( + post(DOCUMENTS_URL) + .with(httpBasic("user-in-tenant-2", "")) + .contentType(MediaType.APPLICATION_JSON) + .content("{ \"title\": \"" + titleT2 + "\" }")) + .andExpect(status().isCreated()); + + // Read from tenant-1 — should see titleT1 but not titleT2 + String response1 = + client + .perform(get(DOCUMENTS_URL).with(httpBasic("user-in-tenant-1", ""))) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(); + + JsonNode values1 = objectMapper.readTree(response1).path("value"); + boolean foundT1 = false; + for (JsonNode node : values1) { + assertThat(node.get("title").asText("")).isNotEqualTo(titleT2); + if (titleT1.equals(node.get("title").asText(""))) { + foundT1 = true; + } + } + assertThat(foundT1).isTrue(); + + // Read from tenant-2 — should see titleT2 but not titleT1 + String response2 = + client + .perform(get(DOCUMENTS_URL).with(httpBasic("user-in-tenant-2", ""))) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(); + + JsonNode values2 = objectMapper.readTree(response2).path("value"); + boolean foundT2 = false; + for (JsonNode node : values2) { + assertThat(node.get("title").asText("")).isNotEqualTo(titleT1); + if (titleT2.equals(node.get("title").asText(""))) { + foundT2 = true; + } + } + assertThat(foundT2).isTrue(); + } +} diff --git a/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/mt/SubscribeAndUnsubscribeTest.java b/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/mt/SubscribeAndUnsubscribeTest.java new file mode 100644 index 000000000..aaeb0673d --- /dev/null +++ b/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/mt/SubscribeAndUnsubscribeTest.java @@ -0,0 +1,72 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. + */ +package com.sap.cds.feature.attachments.integrationtests.mt; + +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sap.cds.feature.attachments.integrationtests.mt.utils.SubscriptionEndpointClient; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +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.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("local-with-tenants") +class SubscribeAndUnsubscribeTest { + + private static final String DOCUMENTS_URL = "/odata/v4/MtTestService/Documents"; + + @Autowired MockMvc client; + @Autowired ObjectMapper objectMapper; + + SubscriptionEndpointClient subscriptionEndpointClient; + + @BeforeEach + void setup() { + subscriptionEndpointClient = new SubscriptionEndpointClient(objectMapper, client); + } + + @Test + void subscribeTenant3_thenServiceIsReachable() throws Exception { + subscriptionEndpointClient.subscribeTenant("tenant-3"); + + client + .perform(get(DOCUMENTS_URL).with(httpBasic("user-in-tenant-3", ""))) + .andExpect(status().isOk()); + } + + @Test + void unsubscribeTenant3_thenServiceFails() throws Exception { + subscriptionEndpointClient.subscribeTenant("tenant-3"); + + // Verify it works + client + .perform(get(DOCUMENTS_URL).with(httpBasic("user-in-tenant-3", ""))) + .andExpect(status().isOk()); + + subscriptionEndpointClient.unsubscribeTenant("tenant-3"); + + // Service should fail after unsubscription + client + .perform(get(DOCUMENTS_URL).with(httpBasic("user-in-tenant-3", ""))) + .andExpect(status().isInternalServerError()); + } + + @AfterEach + void tearDown() { + try { + subscriptionEndpointClient.unsubscribeTenant("tenant-3"); + } catch (Exception ignored) { + // best effort cleanup + } + } +} diff --git a/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/mt/utils/SubscriptionEndpointClient.java b/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/mt/utils/SubscriptionEndpointClient.java new file mode 100644 index 000000000..38a6179b7 --- /dev/null +++ b/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/mt/utils/SubscriptionEndpointClient.java @@ -0,0 +1,68 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. + */ +package com.sap.cds.feature.attachments.integrationtests.mt.utils; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +public class SubscriptionEndpointClient { + + private static final String MT_SUBSCRIPTIONS_TENANTS = "/mt/v1.0/subscriptions/tenants/"; + + private final ObjectMapper objectMapper; + private final MockMvc client; + private final String credentials = + "Basic " + Base64.getEncoder().encodeToString("privileged:".getBytes(StandardCharsets.UTF_8)); + + public SubscriptionEndpointClient(ObjectMapper objectMapper, MockMvc client) { + this.objectMapper = objectMapper; + this.client = client; + } + + public void subscribeTenant(String tenant) throws Exception { + SubscriptionPayload payload = new SubscriptionPayload(); + payload.subscribedTenantId = tenant; + payload.subscribedSubdomain = tenant.concat(".sap.com"); + payload.eventType = "CREATE"; + + client + .perform( + put(MT_SUBSCRIPTIONS_TENANTS.concat(payload.subscribedTenantId)) + .header(HttpHeaders.AUTHORIZATION, credentials) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(payload))) + .andExpect(status().isCreated()); + } + + public void unsubscribeTenant(String tenant) throws Exception { + DeletePayload payload = new DeletePayload(); + payload.subscribedTenantId = tenant; + + client + .perform( + delete(MT_SUBSCRIPTIONS_TENANTS.concat(payload.subscribedTenantId)) + .header(HttpHeaders.AUTHORIZATION, credentials) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(payload))) + .andExpect(status().isNoContent()); + } + + static class SubscriptionPayload { + public String subscribedTenantId; + public String subscribedSubdomain; + public String eventType; + } + + static class DeletePayload { + public String subscribedTenantId; + } +} diff --git a/integration-tests/pom.xml b/integration-tests/pom.xml index 213c34492..593ea7971 100644 --- a/integration-tests/pom.xml +++ b/integration-tests/pom.xml @@ -17,7 +17,8 @@ db - srv + generic + mtx-local/srv diff --git a/samples/bookshop/.cdsrc.json b/samples/bookshop/.cdsrc.json index 2c63c0851..d4facf2b9 100644 --- a/samples/bookshop/.cdsrc.json +++ b/samples/bookshop/.cdsrc.json @@ -1,2 +1,18 @@ { + "profiles": [ + "java" + ], + "requires": { + "[production]": { + "multitenancy": true, + "extensibility": true, + "toggles": true, + "auth": "xsuaa" + }, + "[with-mtx]": { + "multitenancy": true, + "extensibility": true, + "toggles": true + } + } } diff --git a/samples/bookshop/.gitignore b/samples/bookshop/.gitignore index 2ecc68df6..47a678e3f 100644 --- a/samples/bookshop/.gitignore +++ b/samples/bookshop/.gitignore @@ -30,5 +30,11 @@ hs_err* .idea .reloadtrigger +Makefile_*.mta +app/portal/ +resources/*.zip +undeploy.json +**/dist/ + # added by cds .cdsrc-private.json diff --git a/samples/bookshop/README.md b/samples/bookshop/README.md index babcff672..7fe10868d 100644 --- a/samples/bookshop/README.md +++ b/samples/bookshop/README.md @@ -120,6 +120,35 @@ This sample uses the default in-memory storage, which stores attachments directl For advanced topics like object store integration, malware scanning, and security configuration, see the [main project documentation](../../README.md). +## Multi-Tenancy + +This sample also supports multi-tenant mode via profiles. + +### Local MTX + +Run the MTX sidecar and the Java app in separate terminals: + +```bash +# Terminal 1: Start the sidecar +cd mtx/sidecar +npm install +cds watch + +# Terminal 2: Start the Java app with MTX profiles +cds watch --profile with-mtx-sidecar +# or: mvn spring-boot:run -Dspring-boot.run.profiles=local-mtxs +``` + +Mock users with tenant assignments: `admin`/`admin` (tenant t1), `erin`/`erin` (tenant t2). + +### Cloud Foundry Deployment + +```bash +cds add xsuaa,attachments # if not already done +cds build --production +cf deploy gen/mta.tar +``` + ## Troubleshooting - **Port conflicts**: If port 8080 is in use, specify a different port: `mvn spring-boot:run -Dspring-boot.run.arguments="--server.port=8081"` diff --git a/samples/bookshop/app/admin-books/package.json b/samples/bookshop/app/admin-books/package.json new file mode 100644 index 000000000..7adc1936c --- /dev/null +++ b/samples/bookshop/app/admin-books/package.json @@ -0,0 +1,13 @@ +{ + "name": "browse", + "version": "1.0.0", + "main": "webapp/index.html", + "scripts": { + "build": "ui5 build preload --clean-dest", + "start": "ui5 serve" + }, + "devDependencies": { + "@ui5/cli": "^4", + "ui5-task-zipper": "^3" + } +} diff --git a/samples/bookshop/app/admin-books/ui5.yaml b/samples/bookshop/app/admin-books/ui5.yaml new file mode 100644 index 000000000..f57db9288 --- /dev/null +++ b/samples/bookshop/app/admin-books/ui5.yaml @@ -0,0 +1,21 @@ +# yaml-language-server: $schema=https://sap.github.io/ui5-tooling/schema/ui5.yaml.json +specVersion: '4.0' +metadata: + name: admin-books +type: application +resources: + configuration: + propertiesFileSourceEncoding: UTF-8 +builder: + resources: + excludes: + - "/test/**" + - "/localService/**" + customTasks: + - name: ui5-task-zipper + afterTask: generateVersionInfo + configuration: + archiveName: admin-books + relativePaths: true + additionalFiles: + - xs-app.json diff --git a/samples/bookshop/app/admin-books/xs-app.json b/samples/bookshop/app/admin-books/xs-app.json new file mode 100644 index 000000000..bc90058f1 --- /dev/null +++ b/samples/bookshop/app/admin-books/xs-app.json @@ -0,0 +1,18 @@ +{ + "authenticationMethod": "route", + "routes": [ + { + "source": "^/?odata/(.*)$", + "target": "/odata/$1", + "destination": "srv-api", + "authenticationType": "xsuaa", + "csrfProtection": true + }, + { + "source": "^(.*)$", + "service": "html5-apps-repo-rt", + "authenticationType": "xsuaa", + "target": "$1" + } + ] +} diff --git a/samples/bookshop/app/browse/package.json b/samples/bookshop/app/browse/package.json new file mode 100644 index 000000000..7adc1936c --- /dev/null +++ b/samples/bookshop/app/browse/package.json @@ -0,0 +1,13 @@ +{ + "name": "browse", + "version": "1.0.0", + "main": "webapp/index.html", + "scripts": { + "build": "ui5 build preload --clean-dest", + "start": "ui5 serve" + }, + "devDependencies": { + "@ui5/cli": "^4", + "ui5-task-zipper": "^3" + } +} diff --git a/samples/bookshop/app/browse/ui5.yaml b/samples/bookshop/app/browse/ui5.yaml new file mode 100644 index 000000000..42e8f5910 --- /dev/null +++ b/samples/bookshop/app/browse/ui5.yaml @@ -0,0 +1,21 @@ +# yaml-language-server: $schema=https://sap.github.io/ui5-tooling/schema/ui5.yaml.json +specVersion: '4.0' +metadata: + name: browse +type: application +resources: + configuration: + propertiesFileSourceEncoding: UTF-8 +builder: + resources: + excludes: + - "/test/**" + - "/localService/**" + customTasks: + - name: ui5-task-zipper + afterTask: generateVersionInfo + configuration: + archiveName: browse + relativePaths: true + additionalFiles: + - xs-app.json diff --git a/samples/bookshop/app/browse/xs-app.json b/samples/bookshop/app/browse/xs-app.json new file mode 100644 index 000000000..bc90058f1 --- /dev/null +++ b/samples/bookshop/app/browse/xs-app.json @@ -0,0 +1,18 @@ +{ + "authenticationMethod": "route", + "routes": [ + { + "source": "^/?odata/(.*)$", + "target": "/odata/$1", + "destination": "srv-api", + "authenticationType": "xsuaa", + "csrfProtection": true + }, + { + "source": "^(.*)$", + "service": "html5-apps-repo-rt", + "authenticationType": "xsuaa", + "target": "$1" + } + ] +} diff --git a/samples/bookshop/db/package.json b/samples/bookshop/db/package.json new file mode 100644 index 000000000..3c1864b90 --- /dev/null +++ b/samples/bookshop/db/package.json @@ -0,0 +1,14 @@ +{ + "name": "deploy", + "dependencies": { + "hdb": "^2.0.0", + "@sap/hdi-deploy": "^5" + }, + "engines": { + "node": "^24.0.0" + }, + "scripts": { + "start": "node node_modules/@sap/hdi-deploy/deploy.js --use-hdb --parameter com.sap.hana.di.table/try_fast_table_migration=true", + "build": "npm i && npx cds build .. --for hana --production" + } +} diff --git a/samples/bookshop/mtx/sidecar/package.json b/samples/bookshop/mtx/sidecar/package.json new file mode 100644 index 000000000..ee933724d --- /dev/null +++ b/samples/bookshop/mtx/sidecar/package.json @@ -0,0 +1,26 @@ +{ + "name": "bookshop-mtx", + "dependencies": { + "@cap-js/hana": "^2", + "@sap/cds": "^9", + "@sap/cds-mtxs": "^3", + "@sap/xssec": "^4", + "express": "^4" + }, + "devDependencies": { + "@cap-js/sqlite": "^2" + }, + "engines": { + "node": ">=20" + }, + "scripts": { + "start": "cds-serve", + "build": "cds build ../.. --for mtx-sidecar --production && npm ci --prefix gen" + }, + "cds": { + "profiles": [ + "mtx-sidecar", + "java" + ] + } +} diff --git a/samples/bookshop/package-lock.json b/samples/bookshop/package-lock.json deleted file mode 100644 index 2527e0311..000000000 --- a/samples/bookshop/package-lock.json +++ /dev/null @@ -1,2022 +0,0 @@ -{ - "name": "bookshop-cds", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "bookshop-cds", - "version": "1.0.0", - "license": "ISC", - "devDependencies": { - "@sap/cds-dk": "^9.3.2" - } - }, - "node_modules/@sap/cds-dk": { - "version": "9.4.3", - "resolved": "https://registry.npmjs.org/@sap/cds-dk/-/cds-dk-9.4.3.tgz", - "integrity": "sha512-kVz08dhBF7Zms1disoXUoEIrR/ctJkZd7gky1I/sww4fwl832elW6ZxTs85HLn+LeF0Gr3/HX+jJoqRy+3GYNg==", - "dev": true, - "hasShrinkwrap": true, - "license": "SEE LICENSE IN LICENSE", - "dependencies": { - "@cap-js/asyncapi": "^1.0.0", - "@cap-js/openapi": "^1.0.0", - "@sap/cds": ">=8.3", - "@sap/cds-mtxs": ">=2", - "@sap/hdi-deploy": "^5", - "axios": "^1", - "express": "^4.17.3", - "hdb": "^0", - "livereload-js": "^4.0.1", - "mustache": "^4.0.1", - "node-watch": ">=0.7", - "ws": "^8.4.2", - "xml-js": "^1.6.11", - "yaml": "^2" - }, - "bin": { - "cds": "bin/cds.js", - "cds-ts": "bin/cds-ts.js", - "cds-tsx": "bin/cds-tsx.js" - }, - "optionalDependencies": { - "@cap-js/sqlite": ">=1" - } - }, - "node_modules/@sap/cds-dk/node_modules/@cap-js/asyncapi": { - "version": "1.0.3", - "integrity": "sha512-vZSWKAe+3qfvZDXV5SSFiObGWmqyS9MDyEADb5PLVT8kzO39qGaSDPv/GzI/gwvRfCayGAjU4ThiBKrFA7Gclg==", - "dev": true, - "license": "SEE LICENSE IN LICENSE", - "peerDependencies": { - "@sap/cds": ">=7.6" - } - }, - "node_modules/@sap/cds-dk/node_modules/@cap-js/db-service": { - "version": "2.6.0", - "integrity": "sha512-t72/FcAYFbPdx+5iV+lVKcwF2MLOx8II3jJdlC1dX/KXQORoS3wDFwWbakP0f/eharE5hfa7KMFJqrSMtDigbQ==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "generic-pool": "^3.9.0" - }, - "peerDependencies": { - "@sap/cds": ">=9" - } - }, - "node_modules/@sap/cds-dk/node_modules/@cap-js/openapi": { - "version": "1.2.3", - "integrity": "sha512-UnEUBrBIjMvYYJTtAmSrnWLKIjnaK9KcCS6pPoVBRgZrMaL0bl/aB3KMH4xzc6LWjtbxzlyI71XC7No4+SKerg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "pluralize": "^8.0.0" - }, - "peerDependencies": { - "@sap/cds": ">=7.6" - } - }, - "node_modules/@sap/cds-dk/node_modules/@cap-js/sqlite": { - "version": "2.0.4", - "integrity": "sha512-QPVkycLJG6EubtjrPeiK4dTI1zPH/nabvhiYnTeg2AbeQ8mbazm5pjmcLrzOOKF/5bGS8KQo2J+49fU5LPRR3A==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@cap-js/db-service": "^2.6.0", - "better-sqlite3": "^12.0.0" - }, - "peerDependencies": { - "@sap/cds": ">=9" - } - }, - "node_modules/@sap/cds-dk/node_modules/@eslint/js": { - "version": "9.38.0", - "integrity": "sha512-UZ1VpFvXf9J06YG9xQBdnzU+kthors6KjhMAl6f4gH4usHyh31rUf2DLGInT8RFYIReYXNSydgPY0V2LuWgl7A==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - } - }, - "node_modules/@sap/cds-dk/node_modules/@sap/cds": { - "version": "9.4.4", - "integrity": "sha512-JJCHeEJF4xzFyZSf2ToocvVE9dyHfNLTRXOauOxlmpfyaLg97G7Qp+L4bD132eB0onBG9bQj3eH8DzBm0hVvIw==", - "dev": true, - "license": "SEE LICENSE IN LICENSE", - "dependencies": { - "@sap/cds-compiler": "^6.3", - "@sap/cds-fiori": "^2", - "js-yaml": "^4.1.0" - }, - "bin": { - "cds-deploy": "bin/deploy.js", - "cds-serve": "bin/serve.js" - }, - "engines": { - "node": ">=20" - }, - "peerDependencies": { - "@eslint/js": "^9", - "express": "^4", - "tar": "^7" - }, - "peerDependenciesMeta": { - "express": { - "optional": true - }, - "tar": { - "optional": true - } - } - }, - "node_modules/@sap/cds-dk/node_modules/@sap/cds-compiler": { - "version": "6.4.6", - "integrity": "sha512-auAjRh9t0KKj4LiGAr/fxikZRIngx9YXVHTJWf0LeaGv0ZpYOi6iWbSnU1XRB2e6hsf+Ou1w5oTOHooC5sZfog==", - "dev": true, - "license": "SEE LICENSE IN LICENSE", - "bin": { - "cdsc": "bin/cdsc.js", - "cdshi": "bin/cdshi.js", - "cdsse": "bin/cdsse.js" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@sap/cds-dk/node_modules/@sap/cds-fiori": { - "version": "2.1.1", - "integrity": "sha512-X+4v4LBAT8HIt0zr28/kJNS15nlNlcM97vAMW+agLrmK134nyBiMwUMcp8BMhxlG9B2PykrnAKH56D9O3tfoBg==", - "dev": true, - "license": "SEE LICENSE IN LICENSE", - "peerDependencies": { - "@sap/cds": ">=8", - "express": "^4" - } - }, - "node_modules/@sap/cds-dk/node_modules/@sap/cds-mtxs": { - "version": "3.4.3", - "integrity": "sha512-vgABFr7huaKWGx2fWHeGom5bVgsQKD7/gqkC7aQ/7yC9hdZdrx0mz4iZ0ASHUZ5PZWp2FWLD+eaJ9sXKUGHgpA==", - "dev": true, - "license": "SEE LICENSE IN LICENSE", - "dependencies": { - "@sap/hdi-deploy": "^5" - }, - "bin": { - "cds-mtx": "bin/cds-mtx.js", - "cds-mtx-migrate": "bin/cds-mtx-migrate.js" - }, - "peerDependencies": { - "@sap/cds": "^9" - } - }, - "node_modules/@sap/cds-dk/node_modules/@sap/hdi": { - "version": "4.8.0", - "integrity": "sha512-tkJmY2ffm6mt4/LFwRBihlQkMxNAXa3ngvRe2N/6+qLIsUNdrH/M03S5mkygXq56K+KoVVZYuradajCusMWwsw==", - "dev": true, - "license": "See LICENSE file", - "dependencies": { - "async": "^3.2.3" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@sap/hana-client": "^2 >= 2.5", - "hdb": "^2 || ^0" - }, - "peerDependenciesMeta": { - "@sap/hana-client": { - "optional": true - }, - "hdb": { - "optional": true - } - } - }, - "node_modules/@sap/cds-dk/node_modules/@sap/hdi-deploy": { - "version": "5.5.1", - "integrity": "sha512-5r9SIkXX7cO+MwRFF32O566sMx6LP1mLin0eT9F+Adqy+0SrdwkWv4JslQzYetiWLuNsfqQljcao62alaxts8A==", - "dev": true, - "license": "See LICENSE file", - "dependencies": { - "@sap/hdi": "^4.8.0", - "@sap/xsenv": "^5.2.0", - "async": "^3.2.6", - "dotenv": "^16.4.5", - "handlebars": "^4.7.8", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=18.x" - }, - "peerDependencies": { - "@sap/hana-client": "^2 >= 2.6", - "hdb": "^2 || ^0" - }, - "peerDependenciesMeta": { - "@sap/hana-client": { - "optional": true - }, - "hdb": { - "optional": true - } - } - }, - "node_modules/@sap/cds-dk/node_modules/@sap/xsenv": { - "version": "5.6.1", - "integrity": "sha512-4pDpsYLNJsLUBWtTSG+TJ8ul5iY0dWDyJgTy2H/WZGZww9CSPLP/39x+syDDTjkggsmZAlo9t7y9TiXMmtAunw==", - "dev": true, - "license": "SEE LICENSE IN LICENSE file", - "dependencies": { - "debug": "4.4.0", - "node-cache": "^5.1.2", - "verror": "1.10.1" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || ^22.0.0" - } - }, - "node_modules/@sap/cds-dk/node_modules/accepts": { - "version": "1.3.8", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/@sap/cds-dk/node_modules/argparse": { - "version": "2.0.1", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, - "node_modules/@sap/cds-dk/node_modules/array-flatten": { - "version": "1.1.1", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@sap/cds-dk/node_modules/assert-plus": { - "version": "1.0.0", - "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8" - } - }, - "node_modules/@sap/cds-dk/node_modules/async": { - "version": "3.2.6", - "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@sap/cds-dk/node_modules/asynckit": { - "version": "0.4.0", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/@sap/cds-dk/node_modules/axios": { - "version": "1.13.1", - "integrity": "sha512-hU4EGxxt+j7TQijx1oYdAjw4xuIp1wRQSsbMFwSthCWeBQur1eF+qJ5iQ5sN3Tw8YRzQNKb8jszgBdMDVqwJcw==", - "dev": true, - "license": "MIT", - "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.4", - "proxy-from-env": "^1.1.0" - } - }, - "node_modules/@sap/cds-dk/node_modules/base64-js": { - "version": "1.5.1", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "optional": true - }, - "node_modules/@sap/cds-dk/node_modules/better-sqlite3": { - "version": "12.4.1", - "integrity": "sha512-3yVdyZhklTiNrtg+4WqHpJpFDd+WHTg2oM7UcR80GqL05AOV0xEJzc6qNvFYoEtE+hRp1n9MpN6/+4yhlGkDXQ==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "dependencies": { - "bindings": "^1.5.0", - "prebuild-install": "^7.1.1" - }, - "engines": { - "node": "20.x || 22.x || 23.x || 24.x" - } - }, - "node_modules/@sap/cds-dk/node_modules/bindings": { - "version": "1.5.0", - "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "file-uri-to-path": "1.0.0" - } - }, - "node_modules/@sap/cds-dk/node_modules/bl": { - "version": "4.1.0", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - } - }, - "node_modules/@sap/cds-dk/node_modules/body-parser": { - "version": "1.20.3", - "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", - "dev": true, - "license": "MIT", - "dependencies": { - "bytes": "3.1.2", - "content-type": "~1.0.5", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "on-finished": "2.4.1", - "qs": "6.13.0", - "raw-body": "2.5.2", - "type-is": "~1.6.18", - "unpipe": "1.0.0" - }, - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/@sap/cds-dk/node_modules/body-parser/node_modules/debug": { - "version": "2.6.9", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/@sap/cds-dk/node_modules/body-parser/node_modules/ms": { - "version": "2.0.0", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true, - "license": "MIT" - }, - "node_modules/@sap/cds-dk/node_modules/braces": { - "version": "3.0.3", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@sap/cds-dk/node_modules/buffer": { - "version": "5.7.1", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "optional": true, - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, - "node_modules/@sap/cds-dk/node_modules/bytes": { - "version": "3.1.2", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/@sap/cds-dk/node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/@sap/cds-dk/node_modules/call-bound": { - "version": "1.0.4", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/@sap/cds-dk/node_modules/chownr": { - "version": "1.1.4", - "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", - "dev": true, - "license": "ISC", - "optional": true - }, - "node_modules/@sap/cds-dk/node_modules/clone": { - "version": "2.1.2", - "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8" - } - }, - "node_modules/@sap/cds-dk/node_modules/combined-stream": { - "version": "1.0.8", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, - "license": "MIT", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/@sap/cds-dk/node_modules/content-disposition": { - "version": "0.5.4", - "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "safe-buffer": "5.2.1" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/@sap/cds-dk/node_modules/content-type": { - "version": "1.0.5", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/@sap/cds-dk/node_modules/cookie": { - "version": "0.7.1", - "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/@sap/cds-dk/node_modules/cookie-signature": { - "version": "1.0.6", - "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@sap/cds-dk/node_modules/core-util-is": { - "version": "1.0.2", - "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@sap/cds-dk/node_modules/debug": { - "version": "4.4.0", - "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/@sap/cds-dk/node_modules/decompress-response": { - "version": "6.0.0", - "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "mimic-response": "^3.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@sap/cds-dk/node_modules/deep-extend": { - "version": "0.6.0", - "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/@sap/cds-dk/node_modules/delayed-stream": { - "version": "1.0.0", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/@sap/cds-dk/node_modules/depd": { - "version": "2.0.0", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/@sap/cds-dk/node_modules/destroy": { - "version": "1.2.0", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/@sap/cds-dk/node_modules/detect-libc": { - "version": "2.1.2", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/@sap/cds-dk/node_modules/dotenv": { - "version": "16.6.1", - "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, - "node_modules/@sap/cds-dk/node_modules/dunder-proto": { - "version": "1.0.1", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/@sap/cds-dk/node_modules/ee-first": { - "version": "1.1.1", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "dev": true, - "license": "MIT" - }, - "node_modules/@sap/cds-dk/node_modules/encodeurl": { - "version": "2.0.0", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/@sap/cds-dk/node_modules/end-of-stream": { - "version": "1.4.5", - "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "once": "^1.4.0" - } - }, - "node_modules/@sap/cds-dk/node_modules/es-define-property": { - "version": "1.0.1", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/@sap/cds-dk/node_modules/es-errors": { - "version": "1.3.0", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/@sap/cds-dk/node_modules/es-object-atoms": { - "version": "1.1.1", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/@sap/cds-dk/node_modules/es-set-tostringtag": { - "version": "2.1.0", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/@sap/cds-dk/node_modules/escape-html": { - "version": "1.0.3", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "dev": true, - "license": "MIT" - }, - "node_modules/@sap/cds-dk/node_modules/etag": { - "version": "1.8.1", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/@sap/cds-dk/node_modules/expand-template": { - "version": "2.0.3", - "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", - "dev": true, - "license": "(MIT OR WTFPL)", - "optional": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/@sap/cds-dk/node_modules/express": { - "version": "4.21.2", - "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", - "dev": true, - "license": "MIT", - "dependencies": { - "accepts": "~1.3.8", - "array-flatten": "1.1.1", - "body-parser": "1.20.3", - "content-disposition": "0.5.4", - "content-type": "~1.0.4", - "cookie": "0.7.1", - "cookie-signature": "1.0.6", - "debug": "2.6.9", - "depd": "2.0.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "finalhandler": "1.3.1", - "fresh": "0.5.2", - "http-errors": "2.0.0", - "merge-descriptors": "1.0.3", - "methods": "~1.1.2", - "on-finished": "2.4.1", - "parseurl": "~1.3.3", - "path-to-regexp": "0.1.12", - "proxy-addr": "~2.0.7", - "qs": "6.13.0", - "range-parser": "~1.2.1", - "safe-buffer": "5.2.1", - "send": "0.19.0", - "serve-static": "1.16.2", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "type-is": "~1.6.18", - "utils-merge": "1.0.1", - "vary": "~1.1.2" - }, - "engines": { - "node": ">= 0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/@sap/cds-dk/node_modules/express/node_modules/debug": { - "version": "2.6.9", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/@sap/cds-dk/node_modules/express/node_modules/ms": { - "version": "2.0.0", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true, - "license": "MIT" - }, - "node_modules/@sap/cds-dk/node_modules/extsprintf": { - "version": "1.4.1", - "integrity": "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==", - "dev": true, - "engines": [ - "node >=0.6.0" - ], - "license": "MIT" - }, - "node_modules/@sap/cds-dk/node_modules/file-uri-to-path": { - "version": "1.0.0", - "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", - "dev": true, - "license": "MIT", - "optional": true - }, - "node_modules/@sap/cds-dk/node_modules/fill-range": { - "version": "7.1.1", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@sap/cds-dk/node_modules/finalhandler": { - "version": "1.3.1", - "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "on-finished": "2.4.1", - "parseurl": "~1.3.3", - "statuses": "2.0.1", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/@sap/cds-dk/node_modules/finalhandler/node_modules/debug": { - "version": "2.6.9", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/@sap/cds-dk/node_modules/finalhandler/node_modules/ms": { - "version": "2.0.0", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true, - "license": "MIT" - }, - "node_modules/@sap/cds-dk/node_modules/follow-redirects": { - "version": "1.15.11", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, - "node_modules/@sap/cds-dk/node_modules/form-data": { - "version": "4.0.4", - "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", - "dev": true, - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/@sap/cds-dk/node_modules/forwarded": { - "version": "0.2.0", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/@sap/cds-dk/node_modules/fresh": { - "version": "0.5.2", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/@sap/cds-dk/node_modules/fs-constants": { - "version": "1.0.0", - "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", - "dev": true, - "license": "MIT", - "optional": true - }, - "node_modules/@sap/cds-dk/node_modules/function-bind": { - "version": "1.1.2", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/@sap/cds-dk/node_modules/generic-pool": { - "version": "3.9.0", - "integrity": "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 4" - } - }, - "node_modules/@sap/cds-dk/node_modules/get-intrinsic": { - "version": "1.3.0", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/@sap/cds-dk/node_modules/get-proto": { - "version": "1.0.1", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/@sap/cds-dk/node_modules/github-from-package": { - "version": "0.0.0", - "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", - "dev": true, - "license": "MIT", - "optional": true - }, - "node_modules/@sap/cds-dk/node_modules/gopd": { - "version": "1.2.0", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/@sap/cds-dk/node_modules/handlebars": { - "version": "4.7.8", - "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "minimist": "^1.2.5", - "neo-async": "^2.6.2", - "source-map": "^0.6.1", - "wordwrap": "^1.0.0" - }, - "bin": { - "handlebars": "bin/handlebars" - }, - "engines": { - "node": ">=0.4.7" - }, - "optionalDependencies": { - "uglify-js": "^3.1.4" - } - }, - "node_modules/@sap/cds-dk/node_modules/has-symbols": { - "version": "1.1.0", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/@sap/cds-dk/node_modules/has-tostringtag": { - "version": "1.0.2", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/@sap/cds-dk/node_modules/hasown": { - "version": "2.0.2", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/@sap/cds-dk/node_modules/hdb": { - "version": "0.19.12", - "integrity": "sha512-vv+cjmvr6fNH/s0Q2zOZc4sEjMpSC0KuacFn8dp3L38qM3RA2LLeX70wWhZLESpwvwUf1pQkRfUhZeooFSmv3A==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "iconv-lite": "^0.4.18" - }, - "engines": { - "node": ">= 0.12" - } - }, - "node_modules/@sap/cds-dk/node_modules/http-errors": { - "version": "2.0.0", - "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "depd": "2.0.0", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "toidentifier": "1.0.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/@sap/cds-dk/node_modules/iconv-lite": { - "version": "0.4.24", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "dev": true, - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/@sap/cds-dk/node_modules/ieee754": { - "version": "1.2.1", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "BSD-3-Clause", - "optional": true - }, - "node_modules/@sap/cds-dk/node_modules/inherits": { - "version": "2.0.4", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/@sap/cds-dk/node_modules/ini": { - "version": "1.3.8", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "dev": true, - "license": "ISC", - "optional": true - }, - "node_modules/@sap/cds-dk/node_modules/ipaddr.js": { - "version": "1.9.1", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/@sap/cds-dk/node_modules/is-number": { - "version": "7.0.0", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/@sap/cds-dk/node_modules/js-yaml": { - "version": "4.1.0", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/@sap/cds-dk/node_modules/livereload-js": { - "version": "4.0.2", - "integrity": "sha512-Fy7VwgQNiOkynYyNBTo3v9hQUhcW5pFAheJN148+DTgpShjsy/22pLHKKwDK5v0kOsZsJBK+6q1PMgLvRmrwFQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@sap/cds-dk/node_modules/math-intrinsics": { - "version": "1.1.0", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/@sap/cds-dk/node_modules/media-typer": { - "version": "0.3.0", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/@sap/cds-dk/node_modules/merge-descriptors": { - "version": "1.0.3", - "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@sap/cds-dk/node_modules/methods": { - "version": "1.1.2", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/@sap/cds-dk/node_modules/micromatch": { - "version": "4.0.8", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/@sap/cds-dk/node_modules/micromatch/node_modules/picomatch": { - "version": "2.3.1", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/@sap/cds-dk/node_modules/mime": { - "version": "1.6.0", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "dev": true, - "license": "MIT", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@sap/cds-dk/node_modules/mime-db": { - "version": "1.52.0", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/@sap/cds-dk/node_modules/mime-types": { - "version": "2.1.35", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/@sap/cds-dk/node_modules/mimic-response": { - "version": "3.1.0", - "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@sap/cds-dk/node_modules/minimist": { - "version": "1.2.8", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/@sap/cds-dk/node_modules/mkdirp-classic": { - "version": "0.5.3", - "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", - "dev": true, - "license": "MIT", - "optional": true - }, - "node_modules/@sap/cds-dk/node_modules/ms": { - "version": "2.1.3", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@sap/cds-dk/node_modules/mustache": { - "version": "4.2.0", - "integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==", - "dev": true, - "license": "MIT", - "bin": { - "mustache": "bin/mustache" - } - }, - "node_modules/@sap/cds-dk/node_modules/napi-build-utils": { - "version": "2.0.0", - "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", - "dev": true, - "license": "MIT", - "optional": true - }, - "node_modules/@sap/cds-dk/node_modules/negotiator": { - "version": "0.6.3", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/@sap/cds-dk/node_modules/neo-async": { - "version": "2.6.2", - "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@sap/cds-dk/node_modules/node-abi": { - "version": "3.79.0", - "integrity": "sha512-Pr/5KdBQGG8TirdkS0qN3B+f3eo8zTOfZQWAxHoJqopMz2/uvRnG+S4fWu/6AZxKei2CP2p/psdQ5HFC2Ap5BA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "semver": "^7.3.5" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@sap/cds-dk/node_modules/node-cache": { - "version": "5.1.2", - "integrity": "sha512-t1QzWwnk4sjLWaQAS8CHgOJ+RAfmHpxFWmc36IWTiWHQfs0w5JDMBS1b1ZxQteo0vVVuWJvIUKHDkkeK7vIGCg==", - "dev": true, - "license": "MIT", - "dependencies": { - "clone": "2.x" - }, - "engines": { - "node": ">= 8.0.0" - } - }, - "node_modules/@sap/cds-dk/node_modules/node-watch": { - "version": "0.7.4", - "integrity": "sha512-RinNxoz4W1cep1b928fuFhvAQ5ag/+1UlMDV7rbyGthBIgsiEouS4kvRayvvboxii4m8eolKOIBo3OjDqbc+uQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/@sap/cds-dk/node_modules/object-inspect": { - "version": "1.13.4", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/@sap/cds-dk/node_modules/on-finished": { - "version": "2.4.1", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "dev": true, - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/@sap/cds-dk/node_modules/once": { - "version": "1.4.0", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, - "license": "ISC", - "optional": true, - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/@sap/cds-dk/node_modules/parseurl": { - "version": "1.3.3", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/@sap/cds-dk/node_modules/path-to-regexp": { - "version": "0.1.12", - "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@sap/cds-dk/node_modules/pluralize": { - "version": "8.0.0", - "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/@sap/cds-dk/node_modules/prebuild-install": { - "version": "7.1.3", - "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "detect-libc": "^2.0.0", - "expand-template": "^2.0.3", - "github-from-package": "0.0.0", - "minimist": "^1.2.3", - "mkdirp-classic": "^0.5.3", - "napi-build-utils": "^2.0.0", - "node-abi": "^3.3.0", - "pump": "^3.0.0", - "rc": "^1.2.7", - "simple-get": "^4.0.0", - "tar-fs": "^2.0.0", - "tunnel-agent": "^0.6.0" - }, - "bin": { - "prebuild-install": "bin.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@sap/cds-dk/node_modules/proxy-addr": { - "version": "2.0.7", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/@sap/cds-dk/node_modules/proxy-from-env": { - "version": "1.1.0", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@sap/cds-dk/node_modules/pump": { - "version": "3.0.3", - "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, - "node_modules/@sap/cds-dk/node_modules/qs": { - "version": "6.13.0", - "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.0.6" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/@sap/cds-dk/node_modules/range-parser": { - "version": "1.2.1", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/@sap/cds-dk/node_modules/raw-body": { - "version": "2.5.2", - "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", - "dev": true, - "license": "MIT", - "dependencies": { - "bytes": "3.1.2", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "unpipe": "1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/@sap/cds-dk/node_modules/rc": { - "version": "1.2.8", - "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", - "dev": true, - "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", - "optional": true, - "dependencies": { - "deep-extend": "^0.6.0", - "ini": "~1.3.0", - "minimist": "^1.2.0", - "strip-json-comments": "~2.0.1" - }, - "bin": { - "rc": "cli.js" - } - }, - "node_modules/@sap/cds-dk/node_modules/readable-stream": { - "version": "3.6.2", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/@sap/cds-dk/node_modules/safe-buffer": { - "version": "5.2.1", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/@sap/cds-dk/node_modules/safer-buffer": { - "version": "2.1.2", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@sap/cds-dk/node_modules/sax": { - "version": "1.4.1", - "integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==", - "dev": true, - "license": "ISC" - }, - "node_modules/@sap/cds-dk/node_modules/semver": { - "version": "7.7.3", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, - "license": "ISC", - "optional": true, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@sap/cds-dk/node_modules/send": { - "version": "0.19.0", - "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "0.5.2", - "http-errors": "2.0.0", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "2.4.1", - "range-parser": "~1.2.1", - "statuses": "2.0.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/@sap/cds-dk/node_modules/send/node_modules/debug": { - "version": "2.6.9", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/@sap/cds-dk/node_modules/send/node_modules/debug/node_modules/ms": { - "version": "2.0.0", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true, - "license": "MIT" - }, - "node_modules/@sap/cds-dk/node_modules/send/node_modules/encodeurl": { - "version": "1.0.2", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/@sap/cds-dk/node_modules/serve-static": { - "version": "1.16.2", - "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", - "dev": true, - "license": "MIT", - "dependencies": { - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "parseurl": "~1.3.3", - "send": "0.19.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/@sap/cds-dk/node_modules/setprototypeof": { - "version": "1.2.0", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "dev": true, - "license": "ISC" - }, - "node_modules/@sap/cds-dk/node_modules/side-channel": { - "version": "1.1.0", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/@sap/cds-dk/node_modules/side-channel-list": { - "version": "1.0.0", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/@sap/cds-dk/node_modules/side-channel-map": { - "version": "1.0.1", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/@sap/cds-dk/node_modules/side-channel-weakmap": { - "version": "1.0.2", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/@sap/cds-dk/node_modules/simple-concat": { - "version": "1.0.1", - "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "optional": true - }, - "node_modules/@sap/cds-dk/node_modules/simple-get": { - "version": "4.0.1", - "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "optional": true, - "dependencies": { - "decompress-response": "^6.0.0", - "once": "^1.3.1", - "simple-concat": "^1.0.0" - } - }, - "node_modules/@sap/cds-dk/node_modules/source-map": { - "version": "0.6.1", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/@sap/cds-dk/node_modules/statuses": { - "version": "2.0.1", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/@sap/cds-dk/node_modules/string_decoder": { - "version": "1.3.0", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, - "node_modules/@sap/cds-dk/node_modules/strip-json-comments": { - "version": "2.0.1", - "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/@sap/cds-dk/node_modules/tar-fs": { - "version": "2.1.4", - "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "chownr": "^1.1.1", - "mkdirp-classic": "^0.5.2", - "pump": "^3.0.0", - "tar-stream": "^2.1.4" - } - }, - "node_modules/@sap/cds-dk/node_modules/tar-stream": { - "version": "2.2.0", - "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "bl": "^4.0.3", - "end-of-stream": "^1.4.1", - "fs-constants": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/@sap/cds-dk/node_modules/to-regex-range": { - "version": "5.0.1", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/@sap/cds-dk/node_modules/toidentifier": { - "version": "1.0.1", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.6" - } - }, - "node_modules/@sap/cds-dk/node_modules/tunnel-agent": { - "version": "0.6.0", - "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "safe-buffer": "^5.0.1" - }, - "engines": { - "node": "*" - } - }, - "node_modules/@sap/cds-dk/node_modules/type-is": { - "version": "1.6.18", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/@sap/cds-dk/node_modules/uglify-js": { - "version": "3.19.3", - "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", - "dev": true, - "license": "BSD-2-Clause", - "optional": true, - "bin": { - "uglifyjs": "bin/uglifyjs" - }, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/@sap/cds-dk/node_modules/unpipe": { - "version": "1.0.0", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/@sap/cds-dk/node_modules/util-deprecate": { - "version": "1.0.2", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, - "license": "MIT", - "optional": true - }, - "node_modules/@sap/cds-dk/node_modules/utils-merge": { - "version": "1.0.1", - "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/@sap/cds-dk/node_modules/vary": { - "version": "1.1.2", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/@sap/cds-dk/node_modules/verror": { - "version": "1.10.1", - "integrity": "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==", - "dev": true, - "license": "MIT", - "dependencies": { - "assert-plus": "^1.0.0", - "core-util-is": "1.0.2", - "extsprintf": "^1.2.0" - }, - "engines": { - "node": ">=0.6.0" - } - }, - "node_modules/@sap/cds-dk/node_modules/wordwrap": { - "version": "1.0.0", - "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/@sap/cds-dk/node_modules/wrappy": { - "version": "1.0.2", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, - "license": "ISC", - "optional": true - }, - "node_modules/@sap/cds-dk/node_modules/ws": { - "version": "8.18.3", - "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/@sap/cds-dk/node_modules/xml-js": { - "version": "1.6.11", - "integrity": "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==", - "dev": true, - "license": "MIT", - "dependencies": { - "sax": "^1.2.4" - }, - "bin": { - "xml-js": "bin/cli.js" - } - }, - "node_modules/@sap/cds-dk/node_modules/yaml": { - "version": "2.8.1", - "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", - "dev": true, - "license": "ISC", - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - } - } - } -} diff --git a/samples/bookshop/package.json b/samples/bookshop/package.json index 546722325..7fa281120 100644 --- a/samples/bookshop/package.json +++ b/samples/bookshop/package.json @@ -6,5 +6,8 @@ "repository": "", "devDependencies": { "@sap/cds-dk": "^9.3.2" + }, + "dependencies": { + "@sap/cds-mtxs": "^3.8.1" } } diff --git a/samples/bookshop/srv/attachments.cds b/samples/bookshop/srv/attachments.cds index 5a54d43f0..8603e44ba 100644 --- a/samples/bookshop/srv/attachments.cds +++ b/samples/bookshop/srv/attachments.cds @@ -9,8 +9,6 @@ extend my.Books with { sizeLimitedAttachments : Composition of many Attachments; @UI.Hidden mediaValidatedAttachments : Composition of many Attachments; - @UI.Hidden - anotherMediaValidatedAttachments : Composition of many Attachments; } annotate my.Books.sizeLimitedAttachments with { @@ -42,7 +40,7 @@ annotate adminService.Books with @(UI.Facets: [{ $Type : 'UI.ReferenceFacet', ID : 'AttachmentsFacet', Label : '{i18n>attachments}', - Target: 'mediaValidatedAttachments/@UI.LineItem' + Target: 'attachments/@UI.LineItem' }]); diff --git a/samples/bookshop/srv/pom.xml b/samples/bookshop/srv/pom.xml index 5cb9299fe..d480ddd60 100644 --- a/samples/bookshop/srv/pom.xml +++ b/samples/bookshop/srv/pom.xml @@ -1,7 +1,4 @@ - - + 4.0.0 @@ -47,6 +44,18 @@ runtime + + org.xerial + sqlite-jdbc + runtime + + + + com.sap.cds + cds-starter-cloudfoundry + runtime + + org.springframework.boot spring-boot-starter-security @@ -57,6 +66,19 @@ com.sap.cds cds-feature-attachments + + + + com.sap.cds + cds-feature-mt + runtime + + + + org.springframework.boot + spring-boot-starter-actuator + + @@ -151,4 +173,4 @@ - \ No newline at end of file + diff --git a/samples/bookshop/srv/src/main/resources/application.yaml b/samples/bookshop/srv/src/main/resources/application.yaml index bed4c390f..f655cc3d8 100644 --- a/samples/bookshop/srv/src/main/resources/application.yaml +++ b/samples/bookshop/srv/src/main/resources/application.yaml @@ -19,4 +19,45 @@ cds: password: user data-source: auto-config: - enabled: false \ No newline at end of file + enabled: false +--- +spring: + config: + activate: + on-profile: local-mtxs +cds: + multi-tenancy: + mtxs: + enabled: true + sidecar: + url: http://localhost:4005 + security: + mock: + users: + admin: + password: admin + tenant: t1 + roles: + - admin + user: + password: user + tenant: t1 + erin: + password: erin + tenant: t2 + roles: + - admin +--- +management: + endpoint: + health: + show-components: always + probes.enabled: true + endpoints: + web: + exposure: + include: health + health: + defaults.enabled: false + ping.enabled: true + db.enabled: true diff --git a/storage-targets/cds-feature-attachments-oss/README.md b/storage-targets/cds-feature-attachments-oss/README.md index 78b6e9716..2db6f168e 100644 --- a/storage-targets/cds-feature-attachments-oss/README.md +++ b/storage-targets/cds-feature-attachments-oss/README.md @@ -97,4 +97,22 @@ This artifact provides custom handlers for events from the [AttachmentService](. ### Multitenancy -Multitenancy is not directly supported. All attachments are stored in a flat structure within the provided bucket, which might be shared across tenants. +The plugin supports multi-tenancy scenarios with shared object store instances. + +#### Shared Object Store Instance + +To configure a shared object store instance, set the object store kind to `shared`: + +```yaml +cds: + attachments: + objectStore: + kind: shared +``` + +To ensure tenant isolation when using a shared object store instance, the plugin prefixes object keys with the tenant ID. When a tenant unsubscribes, all objects prefixed with that tenant's ID are automatically cleaned up from the shared bucket. + +#### Separate Object Store Instances + +> [!NOTE] +> Separate object store instances per tenant are not yet supported. This feature is planned for a future release. diff --git a/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/client/AWSClient.java b/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/client/AWSClient.java index 04084d9f3..9a7d592e8 100644 --- a/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/client/AWSClient.java +++ b/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/client/AWSClient.java @@ -6,6 +6,8 @@ import com.sap.cds.feature.attachments.oss.handler.ObjectStoreServiceException; import com.sap.cloud.environment.servicebinding.api.ServiceBinding; import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutorService; @@ -18,11 +20,18 @@ import software.amazon.awssdk.regions.Region; import software.amazon.awssdk.services.s3.S3AsyncClient; import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.Delete; import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; import software.amazon.awssdk.services.s3.model.DeleteObjectResponse; +import software.amazon.awssdk.services.s3.model.DeleteObjectsRequest; +import software.amazon.awssdk.services.s3.model.DeleteObjectsResponse; import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.ListObjectsV2Request; +import software.amazon.awssdk.services.s3.model.ListObjectsV2Response; +import software.amazon.awssdk.services.s3.model.ObjectIdentifier; import software.amazon.awssdk.services.s3.model.PutObjectRequest; import software.amazon.awssdk.services.s3.model.PutObjectResponse; +import software.amazon.awssdk.services.s3.model.S3Error; public class AWSClient implements OSClient { private final S3Client s3Client; @@ -55,6 +64,14 @@ public AWSClient(ServiceBinding binding, ExecutorService executor) { logger.info("Initialized AWS S3 client"); } + AWSClient( + S3Client s3Client, S3AsyncClient s3AsyncClient, String bucketName, ExecutorService executor) { + this.s3Client = s3Client; + this.s3AsyncClient = s3AsyncClient; + this.bucketName = bucketName; + this.executor = executor; + } + @Override public Future uploadContent( InputStream content, String completeFileName, String contentType) { @@ -129,4 +146,55 @@ public Future readContent(String completeFileName) { } }); } + + @Override + public Future deleteContentByPrefix(String prefix) { + return executor.submit( + () -> { + try { + List allFailedKeys = new ArrayList<>(); + ListObjectsV2Request listReq = + ListObjectsV2Request.builder().bucket(this.bucketName).prefix(prefix).build(); + ListObjectsV2Response listResp; + do { + listResp = s3Client.listObjectsV2(listReq); + if (!listResp.contents().isEmpty()) { + List keys = + listResp.contents().stream() + .map(obj -> ObjectIdentifier.builder().key(obj.key()).build()) + .toList(); + DeleteObjectsRequest deleteReq = + DeleteObjectsRequest.builder() + .bucket(this.bucketName) + .delete(Delete.builder().objects(keys).build()) + .build(); + DeleteObjectsResponse deleteResp = s3Client.deleteObjects(deleteReq); + if (deleteResp.hasErrors() && !deleteResp.errors().isEmpty()) { + List failedKeys = deleteResp.errors().stream().map(S3Error::key).toList(); + logger.warn( + "Failed to delete {} objects during prefix cleanup: {}", + failedKeys.size(), + failedKeys); + allFailedKeys.addAll(failedKeys); + } + } + listReq = + listReq.toBuilder().continuationToken(listResp.nextContinuationToken()).build(); + } while (listResp.isTruncated()); + if (!allFailedKeys.isEmpty()) { + throw new ObjectStoreServiceException( + "Partial failure during prefix cleanup: " + + allFailedKeys.size() + + " objects could not be deleted: " + + allFailedKeys); + } + } catch (ObjectStoreServiceException e) { + throw e; + } catch (RuntimeException e) { + throw new ObjectStoreServiceException( + "Failed to delete objects by prefix from the AWS Object Store", e); + } + return null; + }); + } } diff --git a/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/client/AzureClient.java b/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/client/AzureClient.java index 62c0f6fb9..63f6afaa5 100644 --- a/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/client/AzureClient.java +++ b/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/client/AzureClient.java @@ -6,11 +6,16 @@ import com.azure.storage.blob.BlobClient; import com.azure.storage.blob.BlobContainerClient; import com.azure.storage.blob.BlobContainerClientBuilder; +import com.azure.storage.blob.models.BlobItem; +import com.azure.storage.blob.models.ListBlobsOptions; import com.azure.storage.blob.specialized.BlobOutputStream; import com.azure.storage.blob.specialized.BlockBlobClient; import com.sap.cds.feature.attachments.oss.handler.ObjectStoreServiceException; import com.sap.cloud.environment.servicebinding.api.ServiceBinding; import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Future; import org.slf4j.Logger; @@ -33,6 +38,11 @@ public AzureClient(ServiceBinding binding, ExecutorService executor) { logger.info("Initialized Azure Blob Storage client"); } + AzureClient(BlobContainerClient blobContainerClient, ExecutorService executor) { + this.blobContainerClient = blobContainerClient; + this.executor = executor; + } + @Override public Future uploadContent( InputStream content, String completeFileName, String contentType) { @@ -86,4 +96,53 @@ public Future readContent(String completeFileName) { } }); } + + @Override + public Future deleteContentByPrefix(String prefix) { + return executor.submit( + () -> { + try { + ListBlobsOptions options = new ListBlobsOptions().setPrefix(prefix); + int batchSize = 1000; + List batch = new ArrayList<>(batchSize); + for (BlobItem blobItem : blobContainerClient.listBlobs(options, null)) { + batch.add(blobItem.getName()); + if (batch.size() >= batchSize) { + deleteBatch(batch); + batch.clear(); + } + } + if (!batch.isEmpty()) { + deleteBatch(batch); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new ObjectStoreServiceException( + "Interrupted while deleting objects by prefix from the Azure Object Store", e); + } catch (ExecutionException e) { + throw new ObjectStoreServiceException( + "Failed to delete objects by prefix from the Azure Object Store", e); + } catch (RuntimeException e) { + throw new ObjectStoreServiceException( + "Failed to delete objects by prefix from the Azure Object Store", e); + } + return null; + }); + } + + private void deleteBatch(List blobNames) throws InterruptedException, ExecutionException { + List> deleteFutures = + blobNames.stream() + .map( + name -> + executor.submit( + () -> { + blobContainerClient.getBlobClient(name).delete(); + return (Void) null; + })) + .toList(); + for (Future f : deleteFutures) { + f.get(); + } + } } diff --git a/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/client/GoogleClient.java b/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/client/GoogleClient.java index c502898ee..a4c4bfa86 100644 --- a/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/client/GoogleClient.java +++ b/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/client/GoogleClient.java @@ -19,7 +19,9 @@ import java.io.InputStream; import java.nio.ByteBuffer; import java.nio.channels.Channels; +import java.util.ArrayList; import java.util.Base64; +import java.util.List; import java.util.concurrent.ExecutorService; import java.util.concurrent.Future; import org.slf4j.Logger; @@ -54,6 +56,12 @@ public GoogleClient(ServiceBinding binding, ExecutorService executor) { logger.info("Initialized client for Google Cloud Storage with binding: {}", binding); } + GoogleClient(Storage storage, String bucketName, ExecutorService executor) { + this.storage = storage; + this.bucketName = bucketName; + this.executor = executor; + } + @Override public Future uploadContent( InputStream content, String completeFileName, String contentType) { @@ -134,4 +142,35 @@ public Future readContent(String completeFileName) { } }); } + + @Override + public Future deleteContentByPrefix(String prefix) { + return executor.submit( + () -> { + try { + Page blobs = + storage.list( + bucketName, + Storage.BlobListOption.prefix(prefix), + Storage.BlobListOption.versions(true)); + List blobIds = new ArrayList<>(); + for (Blob blob : blobs.iterateAll()) { + blobIds.add(BlobId.of(bucketName, blob.getName(), blob.getGeneration())); + } + if (!blobIds.isEmpty()) { + List results = storage.delete(blobIds); + for (int i = 0; i < results.size(); i++) { + if (!results.get(i)) { + logger.warn( + "Failed to delete blob {} during prefix cleanup", blobIds.get(i).getName()); + } + } + } + } catch (RuntimeException e) { + throw new ObjectStoreServiceException( + "Failed to delete objects by prefix from Google Object Store", e); + } + return null; + }); + } } diff --git a/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/client/OSClient.java b/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/client/OSClient.java index a690dd7bb..63f5a0081 100644 --- a/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/client/OSClient.java +++ b/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/client/OSClient.java @@ -4,14 +4,47 @@ package com.sap.cds.feature.attachments.oss.client; import java.io.InputStream; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.Future; /** The {@link OSClient} is the connection to the object store service. */ public interface OSClient { + /** + * Uploads content to the object store. + * + * @param content the input stream of the file content + * @param completeFileName the object key under which to store the content + * @param contentType the MIME type of the content + * @return a {@link Future} that completes when the upload finishes + */ Future uploadContent(InputStream content, String completeFileName, String contentType); + /** + * Deletes a single object from the object store. + * + * @param completeFileName the object key to delete + * @return a {@link Future} that completes when the deletion finishes + */ Future deleteContent(String completeFileName); + /** + * Reads the content of an object from the object store. + * + * @param completeFileName the object key to read + * @return a {@link Future} containing the content as an {@link InputStream} + */ Future readContent(String completeFileName); + + /** + * Deletes all objects whose keys start with the given prefix. Used for tenant cleanup in shared + * multitenancy mode. + * + * @param prefix the key prefix to match (e.g. {@code "tenantId/"}) + * @return a {@link Future} that completes when all matching objects have been deleted + */ + default Future deleteContentByPrefix(String prefix) { + return CompletableFuture.failedFuture( + new UnsupportedOperationException("deleteContentByPrefix not supported by this client")); + } } diff --git a/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/client/OSClientFactory.java b/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/client/OSClientFactory.java new file mode 100644 index 000000000..e4db97bda --- /dev/null +++ b/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/client/OSClientFactory.java @@ -0,0 +1,80 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. + */ +package com.sap.cds.feature.attachments.oss.client; + +import com.sap.cds.feature.attachments.oss.handler.ObjectStoreServiceException; +import com.sap.cloud.environment.servicebinding.api.ServiceBinding; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.concurrent.ExecutorService; +import java.util.stream.Stream; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Factory for creating {@link OSClient} instances from object store service bindings. Detects the + * storage backend (AWS S3, Azure Blob Storage, Google Cloud Storage) based on the credentials in + * the binding. + */ +public final class OSClientFactory { + + private static final Logger logger = LoggerFactory.getLogger(OSClientFactory.class); + + private OSClientFactory() {} + + /** + * Creates an {@link OSClient} for the given service binding. + * + *
    + *
  • For AWS, the binding must contain a "host" with "aws", "s3", or "amazon". + *
  • For Azure, the binding must contain a "container_uri" with "azure" or "windows". + *
  • For Google, the binding must contain a valid "base64EncodedPrivateKeyData" containing + * "google" or "gcp". + *
+ * + * @param binding the {@link ServiceBinding} containing credentials for the object store service + * @param executor the {@link ExecutorService} for async operations + * @return the appropriate {@link OSClient} implementation + * @throws ObjectStoreServiceException if no valid object store service binding is found + */ + public static OSClient create(ServiceBinding binding, ExecutorService executor) { + final String host = (String) binding.getCredentials().get("host"); // AWS + final String containerUri = (String) binding.getCredentials().get("container_uri"); // Azure + final String base64EncodedPrivateKeyData = + (String) binding.getCredentials().get("base64EncodedPrivateKeyData"); // GCP + + if (host != null && Stream.of("aws", "s3", "amazon").anyMatch(host::contains)) { + logger.info("Detected AWS S3 object store from binding: {}", binding); + return new AWSClient(binding, executor); + } else if (containerUri != null + && Stream.of("azure", "windows").anyMatch(containerUri::contains)) { + logger.info("Detected Azure Blob Storage from binding: {}", binding); + return new AzureClient(binding, executor); + } else if (base64EncodedPrivateKeyData != null) { + String decoded; + try { + decoded = + new String( + Base64.getDecoder().decode(base64EncodedPrivateKeyData), StandardCharsets.UTF_8); + } catch (IllegalArgumentException e) { + throw new ObjectStoreServiceException( + "No valid base64EncodedPrivateKeyData found in Google service binding: %s" + .formatted(binding), + e); + } + if (Stream.of("google", "gcp").anyMatch(decoded::contains)) { + logger.info("Detected Google Cloud Storage from binding: {}", binding); + return new GoogleClient(binding, executor); + } else { + throw new ObjectStoreServiceException( + "No valid Google service binding found in binding: %s".formatted(binding)); + } + } else { + throw new ObjectStoreServiceException( + "No valid object store service found in binding: %s. Please ensure you have a valid AWS" + + " S3, Azure Blob Storage, or Google Cloud Storage service binding." + .formatted(binding)); + } + } +} diff --git a/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/configuration/Registration.java b/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/configuration/Registration.java index 6fdbfcfff..3fbca05e2 100644 --- a/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/configuration/Registration.java +++ b/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/configuration/Registration.java @@ -3,7 +3,10 @@ */ package com.sap.cds.feature.attachments.oss.configuration; +import com.sap.cds.feature.attachments.oss.client.OSClient; +import com.sap.cds.feature.attachments.oss.client.OSClientFactory; import com.sap.cds.feature.attachments.oss.handler.OSSAttachmentsServiceHandler; +import com.sap.cds.feature.attachments.oss.handler.TenantCleanupHandler; import com.sap.cds.services.environment.CdsEnvironment; import com.sap.cds.services.runtime.CdsRuntimeConfiguration; import com.sap.cds.services.runtime.CdsRuntimeConfigurer; @@ -11,21 +14,60 @@ import java.util.Optional; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -/** The class registers the event handlers for the attachments feature based on filesystem. */ +/** The class registers the event handlers for the attachments feature based on object store. */ public class Registration implements CdsRuntimeConfiguration { private static final Logger logger = LoggerFactory.getLogger(Registration.class); @Override public void eventHandlers(CdsRuntimeConfigurer configurer) { - Optional bindingOpt = getOSBinding(configurer.getCdsRuntime().getEnvironment()); + CdsEnvironment env = configurer.getCdsRuntime().getEnvironment(); + Optional bindingOpt = getOSBinding(env); if (bindingOpt.isPresent()) { - ExecutorService executor = Executors.newCachedThreadPool(); - // Thread count could be made configurable via CdsProperties if needed in the future. - configurer.eventHandler(new OSSAttachmentsServiceHandler(bindingOpt.get(), executor)); - logger.info("Registered OSS Attachments Service Handler."); + boolean multitenancyEnabled = isMultitenancyEnabled(env); + String objectStoreKind = getObjectStoreKind(env); + + // Fixed thread pool for background I/O operations (upload, download, delete). + // Default 16 is tuned for I/O-bound cloud storage calls, not CPU-bound work. + int threadPoolSize = + env.getProperty("cds.attachments.objectStore.threadPoolSize", Integer.class, 16); + ExecutorService executor = + Executors.newFixedThreadPool( + threadPoolSize, + r -> { + Thread t = new Thread(r, "attachment-oss-tasks"); + t.setDaemon(true); + return t; + }); + Runtime.getRuntime() + .addShutdownHook( + new Thread( + () -> { + executor.shutdown(); + try { + if (!executor.awaitTermination(30, TimeUnit.SECONDS)) { + executor.shutdownNow(); + } + } catch (InterruptedException e) { + executor.shutdownNow(); + Thread.currentThread().interrupt(); + } + })); + OSClient osClient = OSClientFactory.create(bindingOpt.get(), executor); + OSSAttachmentsServiceHandler handler = + new OSSAttachmentsServiceHandler(osClient, multitenancyEnabled, objectStoreKind); + configurer.eventHandler(handler); + + if (multitenancyEnabled && "shared".equals(objectStoreKind)) { + configurer.eventHandler(new TenantCleanupHandler(osClient)); + logger.info( + "Registered OSS Attachments Service Handler with shared multitenancy mode and tenant cleanup."); + } else { + logger.info("Registered OSS Attachments Service Handler."); + } } else { logger.warn( "No service binding to Object Store Service found, hence the OSS Attachments Service Handler is not connected!"); @@ -46,4 +88,13 @@ private static Optional getOSBinding(CdsEnvironment environment) .filter(b -> b.getServiceName().map(name -> name.equals("objectstore")).orElse(false)) .findFirst(); } + + private static boolean isMultitenancyEnabled(CdsEnvironment env) { + return Boolean.TRUE.equals( + env.getProperty("cds.multitenancy.enabled", Boolean.class, Boolean.FALSE)); + } + + private static String getObjectStoreKind(CdsEnvironment env) { + return env.getProperty("cds.attachments.objectStore.kind", String.class, null); + } } diff --git a/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/handler/OSSAttachmentsServiceHandler.java b/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/handler/OSSAttachmentsServiceHandler.java index 9a44d9c8a..cdc4e6bb3 100644 --- a/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/handler/OSSAttachmentsServiceHandler.java +++ b/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/handler/OSSAttachmentsServiceHandler.java @@ -6,27 +6,20 @@ 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.cds4j.sap.attachments.StatusCode; -import com.sap.cds.feature.attachments.oss.client.AWSClient; -import com.sap.cds.feature.attachments.oss.client.AzureClient; -import com.sap.cds.feature.attachments.oss.client.GoogleClient; import com.sap.cds.feature.attachments.oss.client.OSClient; 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.feature.attachments.service.model.servicehandler.AttachmentRestoreEventContext; +import com.sap.cds.services.EventContext; import com.sap.cds.services.ServiceException; import com.sap.cds.services.handler.EventHandler; import com.sap.cds.services.handler.annotations.On; import com.sap.cds.services.handler.annotations.ServiceName; -import com.sap.cloud.environment.servicebinding.api.ServiceBinding; import java.io.InputStream; -import java.nio.charset.StandardCharsets; -import java.util.Base64; import java.util.concurrent.ExecutionException; -import java.util.concurrent.ExecutorService; import java.util.concurrent.Future; -import java.util.stream.Stream; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -39,62 +32,24 @@ public class OSSAttachmentsServiceHandler implements EventHandler { private static final Logger logger = LoggerFactory.getLogger(OSSAttachmentsServiceHandler.class); private final OSClient osClient; + private final boolean multitenancyEnabled; + private final String objectStoreKind; /** - * Creates a new OSSAttachmentsServiceHandler using the provided {@link ServiceBinding}. + * Creates a new OSSAttachmentsServiceHandler with the given {@link OSClient}. * - *

The handler will automatically detect the storage backend (AWS S3, Azure Blob Storage, - * Google Cloud Storage) based on the credentials in the service binding. If no valid binding is - * found, an {@link ObjectStoreServiceException} is thrown. + *

Use {@link com.sap.cds.feature.attachments.oss.client.OSClientFactory#create + * OSClientFactory.create()} to obtain an {@link OSClient} from a service binding. * - *

    - *
  • For AWS, the binding must contain a "host" with "aws", "s3", or "amazon". - *
  • For Azure, the binding must contain a "container_uri" with "azure" or "windows". - *
  • For Google, the binding must contain a valid "base64EncodedPrivateKeyData" containing - * "google" or "gcp". - *
- * - * @param binding the {@link ServiceBinding} containing credentials for the object store service - * @throws ObjectStoreServiceException if no valid object store service binding is found + * @param osClient the object store client for storage operations + * @param multitenancyEnabled whether multitenancy is enabled + * @param objectStoreKind the object store kind (e.g. "shared") */ - public OSSAttachmentsServiceHandler(ServiceBinding binding, ExecutorService executor) { - final String host = (String) binding.getCredentials().get("host"); // AWS - final String containerUri = (String) binding.getCredentials().get("container_uri"); // Azure - final String base64EncodedPrivateKeyData = - (String) binding.getCredentials().get("base64EncodedPrivateKeyData"); // GCP - - // Check the service binding credentials to determine which client to use. - if (host != null && Stream.of("aws", "s3", "amazon").anyMatch(host::contains)) { - this.osClient = new AWSClient(binding, executor); - } else if (containerUri != null - && Stream.of("azure", "windows").anyMatch(containerUri::contains)) { - this.osClient = new AzureClient(binding, executor); - } else if (base64EncodedPrivateKeyData != null) { - String decoded = ""; - try { - decoded = - new String( - Base64.getDecoder().decode(base64EncodedPrivateKeyData), StandardCharsets.UTF_8); - } catch (IllegalArgumentException e) { - throw new ObjectStoreServiceException( - "No valid base64EncodedPrivateKeyData found in Google service binding: %s" - .formatted(binding), - e); - } - // Redeclaring is needed here to make the variable effectively final for the - // lambda expression - final String dec = decoded; - if (Stream.of("google", "gcp").anyMatch(dec::contains)) { - this.osClient = new GoogleClient(binding, executor); - } else { - throw new ObjectStoreServiceException( - "No valid Google service binding found in binding: %s".formatted(binding)); - } - } else { - throw new ObjectStoreServiceException( - "No valid object store service found in binding: %s. Please ensure you have a valid AWS S3, Azure Blob Storage, or Google Cloud Storage service binding." - .formatted(binding)); - } + public OSSAttachmentsServiceHandler( + OSClient osClient, boolean multitenancyEnabled, String objectStoreKind) { + this.osClient = osClient; + this.multitenancyEnabled = multitenancyEnabled; + this.objectStoreKind = objectStoreKind; } @On @@ -106,9 +61,10 @@ void createAttachment(AttachmentCreateEventContext context) { String contentId = (String) context.getAttachmentIds().get(Attachments.ID); MediaData data = context.getData(); String fileName = data.getFileName(); + String objectKey = buildObjectKey(context, contentId); try { - osClient.uploadContent(data.getContent(), contentId, data.getMimeType()).get(); + osClient.uploadContent(data.getContent(), objectKey, data.getMimeType()).get(); logger.info("Uploaded file {}", fileName); context.getData().setStatus(StatusCode.SCANNING); context.setIsInternalStored(false); @@ -126,11 +82,13 @@ void createAttachment(AttachmentCreateEventContext context) { @On void markAttachmentAsDeleted(AttachmentMarkAsDeletedEventContext context) { logger.info( - "OS Attachment Service handler called for marking attachment as deleted with document id {}", + "OS Attachment Service handler called for marking attachment as deleted with document id" + + " {}", context.getContentId()); try { - osClient.deleteContent(context.getContentId()).get(); + String objectKey = buildObjectKey(context, context.getContentId()); + osClient.deleteContent(objectKey).get(); } catch (InterruptedException ex) { Thread.currentThread().interrupt(); throw new ServiceException( @@ -159,7 +117,8 @@ void readAttachment(AttachmentReadEventContext context) { "OS Attachment Service handler called for reading attachment with document id: {}", context.getContentId()); try { - Future future = osClient.readContent(context.getContentId()); + String objectKey = buildObjectKey(context, context.getContentId()); + Future future = osClient.readContent(objectKey); InputStream inputStream = future.get(); // Wait for the content to be read if (inputStream != null) { context.getData().setContent(inputStream); @@ -179,4 +138,57 @@ void readAttachment(AttachmentReadEventContext context) { context.setCompleted(); } } + + /** + * Builds the object key for storage operations. In shared multitenancy mode, the key is prefixed + * with the tenant ID ({@code tenantId/contentId}). Otherwise, the raw content ID is used. + */ + private String buildObjectKey(EventContext context, String contentId) { + if (multitenancyEnabled && "shared".equals(objectStoreKind)) { + String tenant = getTenant(context); + validateTenantId(tenant); + validateContentId(contentId); + return tenant + "/" + contentId; + } + return contentId; + } + + private String getTenant(EventContext context) { + String tenant = context.getUserInfo().getTenant(); + if (tenant == null) { + throw new ServiceException("Tenant ID is required for multitenant attachment operations"); + } + return tenant; + } + + /** + * Validates that the tenant ID is safe for use in object key construction. Rejects null, empty, + * or values containing path separators ({@code /}, {@code \}, {@code ..}) to prevent path + * traversal attacks. + * + * @param tenantId the tenant ID to validate + * @throws ServiceException if the tenant ID is invalid + */ + static void validateTenantId(String tenantId) { + if (tenantId == null + || tenantId.isEmpty() + || tenantId.contains("/") + || tenantId.contains("\\") + || tenantId.contains("..")) { + throw new ServiceException( + "Invalid tenant ID for attachment storage: must not be empty or contain path separators"); + } + } + + private static void validateContentId(String contentId) { + if (contentId == null + || contentId.isEmpty() + || contentId.contains("/") + || contentId.contains("\\") + || contentId.contains("..")) { + throw new ServiceException( + "Invalid content ID for attachment storage: must not be empty or contain path" + + " separators"); + } + } } diff --git a/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/handler/TenantCleanupHandler.java b/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/handler/TenantCleanupHandler.java new file mode 100644 index 000000000..96fb0a230 --- /dev/null +++ b/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/handler/TenantCleanupHandler.java @@ -0,0 +1,44 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. + */ +package com.sap.cds.feature.attachments.oss.handler; + +import com.sap.cds.feature.attachments.oss.client.OSClient; +import com.sap.cds.services.handler.EventHandler; +import com.sap.cds.services.handler.annotations.After; +import com.sap.cds.services.handler.annotations.ServiceName; +import com.sap.cds.services.mt.DeploymentService; +import com.sap.cds.services.mt.UnsubscribeEventContext; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Event handler that cleans up a tenant's attachment data from the shared object store when the + * tenant unsubscribes. Registered only in shared multitenancy mode. + */ +@ServiceName(DeploymentService.DEFAULT_NAME) +public class TenantCleanupHandler implements EventHandler { + + private static final Logger logger = LoggerFactory.getLogger(TenantCleanupHandler.class); + private final OSClient osClient; + + public TenantCleanupHandler(OSClient osClient) { + this.osClient = osClient; + } + + @After(event = DeploymentService.EVENT_UNSUBSCRIBE) + void cleanupTenantData(UnsubscribeEventContext context) { + String tenantId = context.getTenant(); + OSSAttachmentsServiceHandler.validateTenantId(tenantId); + String prefix = tenantId + "/"; + try { + osClient.deleteContentByPrefix(prefix).get(); + logger.info("Cleaned up all objects for tenant {} from shared object store", tenantId); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + logger.error("Interrupted while cleaning up objects for tenant {}", tenantId, e); + } catch (Exception e) { + logger.error("Failed to clean up objects for tenant {}", tenantId, e); + } + } +} diff --git a/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/client/AWSClientTest.java b/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/client/AWSClientTest.java index 032c1a8a2..df25d4fa2 100644 --- a/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/client/AWSClientTest.java +++ b/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/client/AWSClientTest.java @@ -8,16 +8,19 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -import com.sap.cds.feature.attachments.oss.handler.OSSAttachmentsServiceHandler; -import com.sap.cds.feature.attachments.oss.handler.OSSAttachmentsServiceHandlerTestUtils; import com.sap.cds.feature.attachments.oss.handler.ObjectStoreServiceException; import com.sap.cloud.environment.servicebinding.api.ServiceBinding; import java.io.ByteArrayInputStream; import java.io.InputStream; +import java.util.Collections; import java.util.HashMap; +import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; @@ -30,29 +33,31 @@ import software.amazon.awssdk.services.s3.S3Client; import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; import software.amazon.awssdk.services.s3.model.DeleteObjectResponse; +import software.amazon.awssdk.services.s3.model.DeleteObjectsRequest; +import software.amazon.awssdk.services.s3.model.DeleteObjectsResponse; import software.amazon.awssdk.services.s3.model.GetObjectRequest; import software.amazon.awssdk.services.s3.model.GetObjectResponse; +import software.amazon.awssdk.services.s3.model.ListObjectsV2Request; +import software.amazon.awssdk.services.s3.model.ListObjectsV2Response; import software.amazon.awssdk.services.s3.model.PutObjectRequest; import software.amazon.awssdk.services.s3.model.PutObjectResponse; +import software.amazon.awssdk.services.s3.model.S3Object; class AWSClientTest { ExecutorService executor = Executors.newCachedThreadPool(); @Test - void testConstructorWithAwsBindingUsesAwsClient() - throws NoSuchFieldException, IllegalAccessException { - OSSAttachmentsServiceHandler handler = - new OSSAttachmentsServiceHandler(getDummyBinding(), executor); - OSClient client = OSSAttachmentsServiceHandlerTestUtils.getOsClient(handler); + void testFactoryWithAwsBindingCreatesAwsClient() { + OSClient client = OSClientFactory.create(getDummyBinding(), executor); assertInstanceOf(AWSClient.class, client); } @Test void testReadContent() throws Exception { - AWSClient awsClient = new AWSClient(getDummyBinding(), executor); - - // Mock S3Client to return a dummy InputStream S3Client mockS3Client = mock(S3Client.class); + AWSClient awsClient = + new AWSClient(mockS3Client, mock(S3AsyncClient.class), "bucket", executor); + ByteArrayInputStream mockInputStream = new ByteArrayInputStream("test-data".getBytes()); GetObjectResponse mockResponse = mock(GetObjectResponse.class); ResponseInputStream mockResponseInputStream = @@ -60,20 +65,15 @@ void testReadContent() throws Exception { when(mockS3Client.getObject(any(GetObjectRequest.class))).thenReturn(mockResponseInputStream); - var field = AWSClient.class.getDeclaredField("s3Client"); - field.setAccessible(true); - field.set(awsClient, mockS3Client); - InputStream result = awsClient.readContent("test.txt").get(); assertNotNull(result); } @Test void testUploadContent() throws Exception { - AWSClient awsClient = new AWSClient(getDummyBinding(), executor); - - // Mock S3AsyncClient to return a successful PutObjectResponse S3AsyncClient mockAsyncClient = mock(S3AsyncClient.class); + AWSClient awsClient = new AWSClient(mock(S3Client.class), mockAsyncClient, "bucket", executor); + PutObjectResponse mockPutRes = mock(PutObjectResponse.class); SdkHttpResponse mockHttpRes = mock(SdkHttpResponse.class); when(mockHttpRes.isSuccessful()).thenReturn(true); @@ -83,50 +83,35 @@ void testUploadContent() throws Exception { when(mockAsyncClient.putObject(any(PutObjectRequest.class), any(AsyncRequestBody.class))) .thenReturn(successFuture); - var field = AWSClient.class.getDeclaredField("s3AsyncClient"); - field.setAccessible(true); - field.set(awsClient, mockAsyncClient); - - // Should not throw - awsClient .uploadContent(new ByteArrayInputStream("test".getBytes()), "test.txt", "text/plain") .get(); } @Test - void testDeleteContent() throws NoSuchFieldException, IllegalAccessException { - AWSClient awsClient = new AWSClient(getDummyBinding(), executor); - - // Mock S3Client to return a DeleteObjectResponse with successful SdkHttpResponse + void testDeleteContent() { S3Client mockS3Client = mock(S3Client.class); + AWSClient awsClient = + new AWSClient(mockS3Client, mock(S3AsyncClient.class), "bucket", executor); + DeleteObjectResponse mockDelRes = mock(DeleteObjectResponse.class); SdkHttpResponse mockHttpRes = mock(SdkHttpResponse.class); when(mockHttpRes.isSuccessful()).thenReturn(true); when(mockDelRes.sdkHttpResponse()).thenReturn(mockHttpRes); when(mockS3Client.deleteObject(any(DeleteObjectRequest.class))).thenReturn(mockDelRes); - var field = AWSClient.class.getDeclaredField("s3Client"); - field.setAccessible(true); - field.set(awsClient, mockS3Client); - assertDoesNotThrow(() -> awsClient.deleteContent("test.txt").get()); } @Test void testReadContentThrows() throws Exception { - AWSClient awsClient = new AWSClient(getDummyBinding(), executor); - - // Mock S3Client to return a dummy InputStream S3Client mockS3Client = mock(S3Client.class); + AWSClient awsClient = + new AWSClient(mockS3Client, mock(S3AsyncClient.class), "bucket", executor); when(mockS3Client.getObject(any(GetObjectRequest.class))) .thenThrow(new RuntimeException("Simulated S3 failure")); - var field = AWSClient.class.getDeclaredField("s3Client"); - field.setAccessible(true); - field.set(awsClient, mockS3Client); - ExecutionException thrown = assertThrows(ExecutionException.class, () -> awsClient.readContent("test.txt").get()); assertInstanceOf(ObjectStoreServiceException.class, thrown.getCause()); @@ -134,19 +119,14 @@ void testReadContentThrows() throws Exception { @Test void testUploadContentThrows() throws Exception { - AWSClient awsClient = new AWSClient(getDummyBinding(), executor); - - // Mock S3AsyncClient that always fails S3AsyncClient mockAsyncClient = mock(S3AsyncClient.class); + AWSClient awsClient = new AWSClient(mock(S3Client.class), mockAsyncClient, "bucket", executor); + CompletableFuture failedFuture = new CompletableFuture<>(); failedFuture.completeExceptionally(new RuntimeException("Simulated S3 failure")); when(mockAsyncClient.putObject(any(PutObjectRequest.class), any(AsyncRequestBody.class))) .thenReturn((CompletableFuture) failedFuture); - var field = AWSClient.class.getDeclaredField("s3AsyncClient"); - field.setAccessible(true); - field.set(awsClient, mockAsyncClient); - ExecutionException thrown = assertThrows( ExecutionException.class, @@ -161,18 +141,13 @@ void testUploadContentThrows() throws Exception { @Test void testUploadContentThrowsOnPutResponseNull() throws Exception { - AWSClient awsClient = new AWSClient(getDummyBinding(), executor); - - // Mock S3AsyncClient that returns a null PutObjectResponse S3AsyncClient mockAsyncClient = mock(S3AsyncClient.class); + AWSClient awsClient = new AWSClient(mock(S3Client.class), mockAsyncClient, "bucket", executor); + CompletableFuture nullFuture = CompletableFuture.completedFuture(null); when(mockAsyncClient.putObject(any(PutObjectRequest.class), any(AsyncRequestBody.class))) .thenReturn(nullFuture); - var field = AWSClient.class.getDeclaredField("s3AsyncClient"); - field.setAccessible(true); - field.set(awsClient, mockAsyncClient); - ExecutionException thrown = assertThrows( ExecutionException.class, @@ -187,44 +162,131 @@ void testUploadContentThrowsOnPutResponseNull() throws Exception { @Test void testDeleteContentThrowsOnRuntimeException() throws Exception { - AWSClient awsClient = new AWSClient(getDummyBinding(), executor); - - // Mock S3Client to throw a RuntimeException S3Client mockS3Client = mock(S3Client.class); + AWSClient awsClient = + new AWSClient(mockS3Client, mock(S3AsyncClient.class), "bucket", executor); + when(mockS3Client.deleteObject(any(DeleteObjectRequest.class))) .thenThrow(new RuntimeException("Simulated S3 delete failure")); - var field = AWSClient.class.getDeclaredField("s3Client"); - field.setAccessible(true); - field.set(awsClient, mockS3Client); - ExecutionException thrown = assertThrows(ExecutionException.class, () -> awsClient.deleteContent("test.txt").get()); assertInstanceOf(ObjectStoreServiceException.class, thrown.getCause()); } @Test - void testDeleteContentThrowsOnUnsuccessfulResponse() - throws NoSuchFieldException, IllegalAccessException { - AWSClient awsClient = new AWSClient(getDummyBinding(), executor); - - // Mock S3Client to return a DeleteObjectResponse with unsuccessful SdkHttpResponse + void testDeleteContentThrowsOnUnsuccessfulResponse() { S3Client mockS3Client = mock(S3Client.class); + AWSClient awsClient = + new AWSClient(mockS3Client, mock(S3AsyncClient.class), "bucket", executor); + DeleteObjectResponse mockDelRes = mock(DeleteObjectResponse.class); SdkHttpResponse mockHttpRes = mock(SdkHttpResponse.class); when(mockHttpRes.isSuccessful()).thenReturn(false); when(mockDelRes.sdkHttpResponse()).thenReturn(mockHttpRes); when(mockS3Client.deleteObject(any(DeleteObjectRequest.class))).thenReturn(mockDelRes); - var field = AWSClient.class.getDeclaredField("s3Client"); - field.setAccessible(true); - field.set(awsClient, mockS3Client); - ExecutionException thrown = assertThrows(ExecutionException.class, () -> awsClient.deleteContent("test.txt").get()); assertInstanceOf(ObjectStoreServiceException.class, thrown.getCause()); } + @Test + void testDeleteContentByPrefix() throws Exception { + S3Client mockS3Client = mock(S3Client.class); + AWSClient awsClient = + new AWSClient(mockS3Client, mock(S3AsyncClient.class), "bucket", executor); + + S3Object obj1 = S3Object.builder().key("prefix/file1.txt").build(); + S3Object obj2 = S3Object.builder().key("prefix/file2.txt").build(); + + ListObjectsV2Response listResponse = mock(ListObjectsV2Response.class); + when(listResponse.contents()).thenReturn(List.of(obj1, obj2)); + when(listResponse.isTruncated()).thenReturn(false); + when(mockS3Client.listObjectsV2(any(ListObjectsV2Request.class))).thenReturn(listResponse); + + DeleteObjectsResponse deleteResponse = mock(DeleteObjectsResponse.class); + when(deleteResponse.hasErrors()).thenReturn(false); + when(deleteResponse.errors()).thenReturn(Collections.emptyList()); + when(mockS3Client.deleteObjects(any(DeleteObjectsRequest.class))).thenReturn(deleteResponse); + + awsClient.deleteContentByPrefix("prefix/").get(); + + verify(mockS3Client).deleteObjects(any(DeleteObjectsRequest.class)); + } + + @Test + void testDeleteContentByPrefixEmptyList() throws Exception { + S3Client mockS3Client = mock(S3Client.class); + AWSClient awsClient = + new AWSClient(mockS3Client, mock(S3AsyncClient.class), "bucket", executor); + + ListObjectsV2Response listResponse = mock(ListObjectsV2Response.class); + when(listResponse.contents()).thenReturn(Collections.emptyList()); + when(listResponse.isTruncated()).thenReturn(false); + when(mockS3Client.listObjectsV2(any(ListObjectsV2Request.class))).thenReturn(listResponse); + + assertDoesNotThrow(() -> awsClient.deleteContentByPrefix("prefix/").get()); + } + + @Test + void testDeleteContentByPrefixThrowsOnRuntimeException() throws Exception { + S3Client mockS3Client = mock(S3Client.class); + AWSClient awsClient = + new AWSClient(mockS3Client, mock(S3AsyncClient.class), "bucket", executor); + + when(mockS3Client.listObjectsV2(any(ListObjectsV2Request.class))) + .thenThrow(new RuntimeException("Simulated failure")); + + ExecutionException thrown = + assertThrows( + ExecutionException.class, () -> awsClient.deleteContentByPrefix("prefix/").get()); + assertInstanceOf(ObjectStoreServiceException.class, thrown.getCause()); + } + + @Test + void testDeleteContentByPrefixWithPagination() throws Exception { + S3Client mockS3Client = mock(S3Client.class); + AWSClient awsClient = + new AWSClient(mockS3Client, mock(S3AsyncClient.class), "bucket", executor); + + // First page: 2 objects, isTruncated=true + S3Object obj1 = S3Object.builder().key("prefix/file1.txt").build(); + S3Object obj2 = S3Object.builder().key("prefix/file2.txt").build(); + + ListObjectsV2Response firstPage = mock(ListObjectsV2Response.class); + when(firstPage.contents()).thenReturn(List.of(obj1, obj2)); + when(firstPage.isTruncated()).thenReturn(true); + when(firstPage.nextContinuationToken()).thenReturn("token1"); + + // Second page: 1 object, isTruncated=false + S3Object obj3 = S3Object.builder().key("prefix/file3.txt").build(); + + ListObjectsV2Response secondPage = mock(ListObjectsV2Response.class); + when(secondPage.contents()).thenReturn(List.of(obj3)); + when(secondPage.isTruncated()).thenReturn(false); + + // First call returns first page, second call (with token) returns second page + when(mockS3Client.listObjectsV2( + argThat((ListObjectsV2Request req) -> req != null && req.continuationToken() == null))) + .thenReturn(firstPage); + when(mockS3Client.listObjectsV2( + argThat( + (ListObjectsV2Request req) -> + req != null && "token1".equals(req.continuationToken())))) + .thenReturn(secondPage); + + DeleteObjectsResponse deleteResponse = mock(DeleteObjectsResponse.class); + when(deleteResponse.hasErrors()).thenReturn(false); + when(deleteResponse.errors()).thenReturn(Collections.emptyList()); + when(mockS3Client.deleteObjects(any(DeleteObjectsRequest.class))).thenReturn(deleteResponse); + + awsClient.deleteContentByPrefix("prefix/").get(); + + // deleteObjects should be called twice — once per page + verify(mockS3Client, times(2)).deleteObjects(any(DeleteObjectsRequest.class)); + } + private ServiceBinding getDummyBinding() { ServiceBinding binding = mock(ServiceBinding.class); HashMap creds = new HashMap<>(); diff --git a/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/client/AzureClientTest.java b/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/client/AzureClientTest.java index 9b7100468..f1c55570d 100644 --- a/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/client/AzureClientTest.java +++ b/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/client/AzureClientTest.java @@ -7,13 +7,17 @@ import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; +import com.azure.core.http.rest.PagedIterable; import com.azure.storage.blob.BlobClient; import com.azure.storage.blob.BlobContainerClient; +import com.azure.storage.blob.models.BlobItem; +import com.azure.storage.blob.models.ListBlobsOptions; import com.azure.storage.blob.specialized.BlobOutputStream; import com.azure.storage.blob.specialized.BlockBlobClient; import com.sap.cds.feature.attachments.oss.handler.ObjectStoreServiceException; import java.io.IOException; import java.io.InputStream; +import java.util.List; import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -23,25 +27,11 @@ class AzureClientTest { ExecutorService executor = Executors.newCachedThreadPool(); @Test - void testReadContent() - throws NoSuchFieldException, - SecurityException, - IllegalArgumentException, - IllegalAccessException, - InterruptedException, - ExecutionException { - AzureClient azureClient = mock(AzureClient.class, CALLS_REAL_METHODS); - - // Mock BlobContainerClient and BlobClient + void testReadContent() throws InterruptedException, ExecutionException { BlobContainerClient mockContainer = mock(BlobContainerClient.class); BlobClient mockBlobClient = mock(BlobClient.class); + AzureClient azureClient = new AzureClient(mockContainer, executor); - var field = AzureClient.class.getDeclaredField("blobContainerClient"); - field.setAccessible(true); - field.set(azureClient, mockContainer); - var executorField = AzureClient.class.getDeclaredField("executor"); - executorField.setAccessible(true); - executorField.set(azureClient, executor); when(mockContainer.getBlobClient(anyString())).thenReturn(mockBlobClient); // Should not throw @@ -49,26 +39,15 @@ void testReadContent() } @Test - void testUploadContent() - throws NoSuchFieldException, - IllegalAccessException, - InterruptedException, - ExecutionException { - AzureClient azureClient = mock(AzureClient.class, CALLS_REAL_METHODS); - - // Mock BlobContainerClient and BlockBlobClient + void testUploadContent() throws InterruptedException, ExecutionException { BlobContainerClient mockContainer = mock(BlobContainerClient.class); + BlobClient mockBlobClient = mock(BlobClient.class); BlockBlobClient mockBlockBlob = mock(BlockBlobClient.class); BlobOutputStream mockOutputStream = mock(BlobOutputStream.class); + AzureClient azureClient = new AzureClient(mockContainer, executor); - var field = AzureClient.class.getDeclaredField("blobContainerClient"); - field.setAccessible(true); - field.set(azureClient, mockContainer); - var executorField = AzureClient.class.getDeclaredField("executor"); - executorField.setAccessible(true); - executorField.set(azureClient, executor); - when(mockContainer.getBlobClient(anyString())).thenReturn(mock(BlobClient.class)); - when(mockContainer.getBlobClient(anyString()).getBlockBlobClient()).thenReturn(mockBlockBlob); + when(mockContainer.getBlobClient(anyString())).thenReturn(mockBlobClient); + when(mockBlobClient.getBlockBlobClient()).thenReturn(mockBlockBlob); when(mockBlockBlob.getBlobOutputStream()).thenReturn(mockOutputStream); InputStream mockInput = new java.io.ByteArrayInputStream("test-data".getBytes()); @@ -78,23 +57,15 @@ void testUploadContent() } @Test - void testUploadContentThrowsOnIOException() - throws NoSuchFieldException, IllegalAccessException, IOException { - AzureClient azureClient = mock(AzureClient.class, CALLS_REAL_METHODS); - - // Mock BlobContainerClient and BlockBlobClient + void testUploadContentThrowsOnIOException() throws IOException { BlobContainerClient mockContainer = mock(BlobContainerClient.class); + BlobClient mockBlobClient = mock(BlobClient.class); BlockBlobClient mockBlockBlob = mock(BlockBlobClient.class); BlobOutputStream mockOutputStream = mock(BlobOutputStream.class); + AzureClient azureClient = new AzureClient(mockContainer, executor); - var field = AzureClient.class.getDeclaredField("blobContainerClient"); - field.setAccessible(true); - field.set(azureClient, mockContainer); - var executorField = AzureClient.class.getDeclaredField("executor"); - executorField.setAccessible(true); - executorField.set(azureClient, executor); - when(mockContainer.getBlobClient(anyString())).thenReturn(mock(BlobClient.class)); - when(mockContainer.getBlobClient(anyString()).getBlockBlobClient()).thenReturn(mockBlockBlob); + when(mockContainer.getBlobClient(anyString())).thenReturn(mockBlobClient); + when(mockBlobClient.getBlockBlobClient()).thenReturn(mockBlockBlob); when(mockBlockBlob.getBlobOutputStream()).thenReturn(mockOutputStream); // Mock InputStream to throw IOException @@ -109,23 +80,11 @@ void testUploadContentThrowsOnIOException() } @Test - void testDeleteContentThrowsOnRuntimeException() - throws NoSuchFieldException, - SecurityException, - IllegalArgumentException, - IllegalAccessException { - AzureClient azureClient = mock(AzureClient.class, CALLS_REAL_METHODS); - - // Mock BlobContainerClient and BlobClient + void testDeleteContentThrowsOnRuntimeException() { BlobContainerClient mockContainer = mock(BlobContainerClient.class); BlobClient mockBlobClient = mock(BlobClient.class); + AzureClient azureClient = new AzureClient(mockContainer, executor); - var field = AzureClient.class.getDeclaredField("blobContainerClient"); - field.setAccessible(true); - field.set(azureClient, mockContainer); - var executorField = AzureClient.class.getDeclaredField("executor"); - executorField.setAccessible(true); - executorField.set(azureClient, executor); when(mockContainer.getBlobClient(anyString())).thenReturn(mockBlobClient); // Mock delete to throw RuntimeException @@ -137,25 +96,11 @@ void testDeleteContentThrowsOnRuntimeException() } @Test - void testDeleteContent() - throws NoSuchFieldException, - SecurityException, - IllegalArgumentException, - IllegalAccessException, - InterruptedException, - ExecutionException { - AzureClient azureClient = mock(AzureClient.class, CALLS_REAL_METHODS); - - // Mock BlobContainerClient and BlobClient + void testDeleteContent() throws InterruptedException, ExecutionException { BlobContainerClient mockContainer = mock(BlobContainerClient.class); BlobClient mockBlobClient = mock(BlobClient.class); + AzureClient azureClient = new AzureClient(mockContainer, executor); - var field = AzureClient.class.getDeclaredField("blobContainerClient"); - field.setAccessible(true); - field.set(azureClient, mockContainer); - var executorField = AzureClient.class.getDeclaredField("executor"); - executorField.setAccessible(true); - executorField.set(azureClient, executor); when(mockContainer.getBlobClient(anyString())).thenReturn(mockBlobClient); // Should not throw @@ -163,30 +108,54 @@ void testDeleteContent() } @Test - void testReadContentThrowsOnRuntimeException() - throws NoSuchFieldException, - SecurityException, - IllegalArgumentException, - IllegalAccessException { - AzureClient azureClient = mock(AzureClient.class, CALLS_REAL_METHODS); - - // Mock BlobContainerClient and BlobClient + void testReadContentThrowsOnRuntimeException() { BlobContainerClient mockContainer = mock(BlobContainerClient.class); BlobClient mockBlobClient = mock(BlobClient.class); + AzureClient azureClient = new AzureClient(mockContainer, executor); - var field = AzureClient.class.getDeclaredField("blobContainerClient"); - field.setAccessible(true); - field.set(azureClient, mockContainer); - var executorField = AzureClient.class.getDeclaredField("executor"); - executorField.setAccessible(true); - executorField.set(azureClient, executor); when(mockContainer.getBlobClient(anyString())).thenReturn(mockBlobClient); - // Mock delete to throw RuntimeException + // Mock openInputStream to throw RuntimeException doThrow(new RuntimeException("Simulated read failure")).when(mockBlobClient).openInputStream(); ExecutionException thrown = assertThrows(ExecutionException.class, () -> azureClient.readContent("file.txt").get()); assertInstanceOf(ObjectStoreServiceException.class, thrown.getCause()); } + + @Test + void testDeleteContentByPrefix() throws InterruptedException, ExecutionException { + BlobContainerClient mockContainer = mock(BlobContainerClient.class); + BlobClient mockBlobClient = mock(BlobClient.class); + AzureClient azureClient = new AzureClient(mockContainer, executor); + + BlobItem item1 = mock(BlobItem.class); + when(item1.getName()).thenReturn("prefix/file1.txt"); + BlobItem item2 = mock(BlobItem.class); + when(item2.getName()).thenReturn("prefix/file2.txt"); + + @SuppressWarnings("unchecked") + PagedIterable pagedIterable = mock(PagedIterable.class); + when(pagedIterable.iterator()).thenReturn(List.of(item1, item2).iterator()); + when(mockContainer.listBlobs(any(ListBlobsOptions.class), isNull())).thenReturn(pagedIterable); + when(mockContainer.getBlobClient(anyString())).thenReturn(mockBlobClient); + + azureClient.deleteContentByPrefix("prefix/").get(); + + verify(mockBlobClient, times(2)).delete(); + } + + @Test + void testDeleteContentByPrefixThrowsOnRuntimeException() { + BlobContainerClient mockContainer = mock(BlobContainerClient.class); + AzureClient azureClient = new AzureClient(mockContainer, executor); + + when(mockContainer.listBlobs(any(ListBlobsOptions.class), isNull())) + .thenThrow(new RuntimeException("Simulated failure")); + + ExecutionException thrown = + assertThrows( + ExecutionException.class, () -> azureClient.deleteContentByPrefix("prefix/").get()); + assertInstanceOf(ObjectStoreServiceException.class, thrown.getCause()); + } } diff --git a/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/client/GoogleClientTest.java b/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/client/GoogleClientTest.java index f4c2fd188..e959bef4e 100644 --- a/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/client/GoogleClientTest.java +++ b/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/client/GoogleClientTest.java @@ -54,18 +54,12 @@ void testConstructorThrowsOnInvalidCredentials() { } @Test - void testDeleteContent() - throws NoSuchFieldException, - IllegalArgumentException, - IllegalAccessException, - InterruptedException, - ExecutionException { - GoogleClient googleClient = mock(GoogleClient.class, CALLS_REAL_METHODS); + void testDeleteContent() throws InterruptedException, ExecutionException { + Storage mockStorage = mock(Storage.class); + GoogleClient googleClient = new GoogleClient(mockStorage, "my-bucket", executor); String fileName = "file.txt"; - // Mock storage and paging - Storage mockStorage = mock(Storage.class); Page mockPage = mock(Page.class); Blob mockBlob = mock(Blob.class); when(mockBlob.getName()).thenReturn(fileName); @@ -75,48 +69,19 @@ void testDeleteContent() when(mockStorage.list(anyString(), any(), any())).thenReturn(mockPage); when(mockStorage.delete(any(BlobId.class))).thenReturn(true); - // Inject mock storage and bucketName into googleClient using reflection - var field = GoogleClient.class.getDeclaredField("storage"); - field.setAccessible(true); - field.set(googleClient, mockStorage); - var executorField = GoogleClient.class.getDeclaredField("executor"); - executorField.setAccessible(true); - executorField.set(googleClient, executor); - var bucketField = GoogleClient.class.getDeclaredField("bucketName"); - bucketField.setAccessible(true); - bucketField.set(googleClient, "my-bucket"); - // Should not throw googleClient.deleteContent(fileName).get(); } @Test - void testUploadContent() - throws NoSuchFieldException, - IllegalArgumentException, - IllegalAccessException, - InterruptedException, - ExecutionException, - IOException { - GoogleClient googleClient = mock(GoogleClient.class, CALLS_REAL_METHODS); - - // Mock storage and writer + void testUploadContent() throws InterruptedException, ExecutionException, IOException { Storage mockStorage = mock(Storage.class); - WriteChannel mockWriter = mock(WriteChannel.class); + GoogleClient googleClient = new GoogleClient(mockStorage, "my-bucket", executor); - // Inject mock storage and bucketName into googleClient using reflection - var field = GoogleClient.class.getDeclaredField("storage"); - field.setAccessible(true); - field.set(googleClient, mockStorage); - var executorField = GoogleClient.class.getDeclaredField("executor"); - executorField.setAccessible(true); - executorField.set(googleClient, executor); - var bucketField = GoogleClient.class.getDeclaredField("bucketName"); - bucketField.setAccessible(true); - bucketField.set(googleClient, "my-bucket"); + WriteChannel mockWriter = mock(WriteChannel.class); when(mockStorage.writer(any(BlobInfo.class))).thenReturn(mockWriter); - when(mockWriter.write(any(java.nio.ByteBuffer.class))).thenReturn(42); // return any int + when(mockWriter.write(any(java.nio.ByteBuffer.class))).thenReturn(42); InputStream input = new java.io.ByteArrayInputStream("test".getBytes()); // Should not throw @@ -124,44 +89,25 @@ void testUploadContent() } @Test - void testReadContent() - throws NoSuchFieldException, - IllegalArgumentException, - IllegalAccessException, - InterruptedException, - ExecutionException { - GoogleClient googleClient = mock(GoogleClient.class, CALLS_REAL_METHODS); - - // Mock storage and read channel + void testReadContent() throws InterruptedException, ExecutionException { Storage mockStorage = mock(Storage.class); + GoogleClient googleClient = new GoogleClient(mockStorage, "my-bucket", executor); + ReadChannel mockReadChannel = mock(ReadChannel.class); when(mockStorage.reader(any(com.google.cloud.storage.BlobId.class))) .thenReturn(mockReadChannel); - // Inject mock storage and bucketName into googleClient using reflection - var field = GoogleClient.class.getDeclaredField("storage"); - field.setAccessible(true); - field.set(googleClient, mockStorage); - var executorField = GoogleClient.class.getDeclaredField("executor"); - executorField.setAccessible(true); - executorField.set(googleClient, executor); - var bucketField = GoogleClient.class.getDeclaredField("bucketName"); - bucketField.setAccessible(true); - bucketField.set(googleClient, "my-bucket"); - // Should not throw googleClient.readContent("file.txt").get(); } @Test - void testDeleteContentDoesNotWork() - throws NoSuchFieldException, IllegalArgumentException, IllegalAccessException { - GoogleClient googleClient = mock(GoogleClient.class, CALLS_REAL_METHODS); + void testDeleteContentDoesNotWork() { + Storage mockStorage = mock(Storage.class); + GoogleClient googleClient = new GoogleClient(mockStorage, "my-bucket", executor); String fileName = "file.txt"; - // Mock storage and paging - Storage mockStorage = mock(Storage.class); Page mockPage = mock(Page.class); Blob mockBlob = mock(Blob.class); when(mockBlob.getName()).thenReturn(fileName); @@ -171,41 +117,17 @@ void testDeleteContentDoesNotWork() when(mockStorage.list(anyString(), any(), any())).thenReturn(mockPage); when(mockStorage.delete(any(BlobId.class))).thenReturn(false); - // Inject mock storage and bucketName into googleClient using reflection - var field = GoogleClient.class.getDeclaredField("storage"); - field.setAccessible(true); - field.set(googleClient, mockStorage); - var executorField = GoogleClient.class.getDeclaredField("executor"); - executorField.setAccessible(true); - executorField.set(googleClient, executor); - var bucketField = GoogleClient.class.getDeclaredField("bucketName"); - bucketField.setAccessible(true); - bucketField.set(googleClient, "my-bucket"); - ExecutionException thrown = assertThrows(ExecutionException.class, () -> googleClient.deleteContent(fileName).get()); assertInstanceOf(ObjectStoreServiceException.class, thrown.getCause()); } @Test - void testUploadContentThrowsOnIOException() - throws NoSuchFieldException, IllegalArgumentException, IllegalAccessException, IOException { - GoogleClient googleClient = mock(GoogleClient.class, CALLS_REAL_METHODS); - - // Mock storage and writer + void testUploadContentThrowsOnIOException() throws IOException { Storage mockStorage = mock(Storage.class); - WriteChannel mockWriter = mock(WriteChannel.class); + GoogleClient googleClient = new GoogleClient(mockStorage, "my-bucket", executor); - // Inject mock storage and bucketName into googleClient using reflection - var field = GoogleClient.class.getDeclaredField("storage"); - field.setAccessible(true); - field.set(googleClient, mockStorage); - var executorField = GoogleClient.class.getDeclaredField("executor"); - executorField.setAccessible(true); - executorField.set(googleClient, executor); - var bucketField = GoogleClient.class.getDeclaredField("bucketName"); - bucketField.setAccessible(true); - bucketField.set(googleClient, "my-bucket"); + WriteChannel mockWriter = mock(WriteChannel.class); when(mockStorage.writer(any(BlobInfo.class))).thenReturn(mockWriter); // Simulate IOException on write @@ -223,21 +145,11 @@ void testUploadContentThrowsOnIOException() } @Test - void testDeleteContentThrowsOnRuntimeException() - throws NoSuchFieldException, IllegalArgumentException, IllegalAccessException { - GoogleClient googleClient = mock(GoogleClient.class, CALLS_REAL_METHODS); - - // Mock storage and blob to throw RuntimeException on delete + void testDeleteContentThrowsOnRuntimeException() { Storage mockStorage = mock(Storage.class); - Blob mockBlob = mock(Blob.class); + GoogleClient googleClient = new GoogleClient(mockStorage, "my-bucket", executor); - // Inject mock storage into googleClient using reflection - var field = GoogleClient.class.getDeclaredField("storage"); - field.setAccessible(true); - field.set(googleClient, mockStorage); - var executorField = GoogleClient.class.getDeclaredField("executor"); - executorField.setAccessible(true); - executorField.set(googleClient, executor); + Blob mockBlob = mock(Blob.class); when(mockStorage.get(any(String.class), any(String.class))).thenReturn(mockBlob); doThrow(new RuntimeException("Simulated delete failure")).when(mockBlob).delete(); @@ -248,22 +160,10 @@ void testDeleteContentThrowsOnRuntimeException() } @Test - void testReadContentThrowsOnRuntimeException() - throws NoSuchFieldException, IllegalArgumentException, IllegalAccessException { - GoogleClient googleClient = mock(GoogleClient.class, CALLS_REAL_METHODS); - - // Mock storage and blob to throw RuntimeException on reader + void testReadContentThrowsOnRuntimeException() { Storage mockStorage = mock(Storage.class); + GoogleClient googleClient = new GoogleClient(mockStorage, "my-bucket", executor); - // Inject mock storage into googleClient using reflection - var field = GoogleClient.class.getDeclaredField("storage"); - field.setAccessible(true); - field.set(googleClient, mockStorage); - var executorField = GoogleClient.class.getDeclaredField("executor"); - executorField.setAccessible(true); - executorField.set(googleClient, executor); - - // Mock blob.reader() to throw RuntimeException doThrow(new RuntimeException("Simulated read failure")) .when(mockStorage) .reader(any(com.google.cloud.storage.BlobId.class)); @@ -272,4 +172,41 @@ void testReadContentThrowsOnRuntimeException() assertThrows(ExecutionException.class, () -> googleClient.readContent("file.txt").get()); assertInstanceOf(ObjectStoreServiceException.class, thrown.getCause()); } + + @Test + void testDeleteContentByPrefix() throws InterruptedException, ExecutionException { + Storage mockStorage = mock(Storage.class); + GoogleClient googleClient = new GoogleClient(mockStorage, "my-bucket", executor); + + Page mockPage = mock(Page.class); + Blob mockBlob1 = mock(Blob.class); + when(mockBlob1.getName()).thenReturn("prefix/file1.txt"); + when(mockBlob1.getGeneration()).thenReturn(1L); + Blob mockBlob2 = mock(Blob.class); + when(mockBlob2.getName()).thenReturn("prefix/file2.txt"); + when(mockBlob2.getGeneration()).thenReturn(2L); + + Iterator blobIterator = java.util.List.of(mockBlob1, mockBlob2).iterator(); + when(mockPage.iterateAll()).thenReturn(() -> blobIterator); + when(mockStorage.list(anyString(), any(), any())).thenReturn(mockPage); + when(mockStorage.delete(anyList())).thenReturn(java.util.List.of(true, true)); + + googleClient.deleteContentByPrefix("prefix/").get(); + + verify(mockStorage).delete(anyList()); + } + + @Test + void testDeleteContentByPrefixThrowsOnRuntimeException() { + Storage mockStorage = mock(Storage.class); + GoogleClient googleClient = new GoogleClient(mockStorage, "my-bucket", executor); + + when(mockStorage.list(anyString(), any(), any())) + .thenThrow(new RuntimeException("Simulated failure")); + + ExecutionException thrown = + assertThrows( + ExecutionException.class, () -> googleClient.deleteContentByPrefix("prefix/").get()); + assertInstanceOf(ObjectStoreServiceException.class, thrown.getCause()); + } } diff --git a/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/client/OSClientTest.java b/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/client/OSClientTest.java new file mode 100644 index 000000000..48adf9f63 --- /dev/null +++ b/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/client/OSClientTest.java @@ -0,0 +1,40 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. + */ +package com.sap.cds.feature.attachments.oss.client; + +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.concurrent.ExecutionException; +import org.junit.jupiter.api.Test; + +class OSClientTest { + + @Test + void testDefaultDeleteContentByPrefixThrowsUnsupportedOperationException() { + OSClient client = + new OSClient() { + @Override + public java.util.concurrent.Future uploadContent( + java.io.InputStream content, String completeFileName, String contentType) { + return null; + } + + @Override + public java.util.concurrent.Future deleteContent(String completeFileName) { + return null; + } + + @Override + public java.util.concurrent.Future readContent( + String completeFileName) { + return null; + } + }; + + ExecutionException thrown = + assertThrows(ExecutionException.class, () -> client.deleteContentByPrefix("prefix/").get()); + assertInstanceOf(UnsupportedOperationException.class, thrown.getCause()); + } +} diff --git a/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/configuration/RegistrationTest.java b/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/configuration/RegistrationTest.java index a364aa7d7..3987e1a72 100644 --- a/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/configuration/RegistrationTest.java +++ b/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/configuration/RegistrationTest.java @@ -4,7 +4,11 @@ package com.sap.cds.feature.attachments.oss.configuration; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; import com.sap.cds.feature.attachments.oss.handler.OSSAttachmentsServiceHandler; import com.sap.cds.services.environment.CdsEnvironment; @@ -15,37 +19,108 @@ import java.util.Map; import java.util.Optional; import java.util.stream.Stream; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; class RegistrationTest { - @Test - void testEventHandlersRegistersOSSHandler() { - // Arrange - Registration registration = new Registration(); - CdsRuntimeConfigurer configurer = mock(CdsRuntimeConfigurer.class); - CdsRuntime cdsRuntime = mock(CdsRuntime.class); - CdsEnvironment environment = mock(CdsEnvironment.class); - ServiceBinding binding = mock(ServiceBinding.class); + private Registration registration; + private CdsRuntimeConfigurer configurer; + private CdsEnvironment environment; + private ServiceBinding awsBinding; - // Setup valid AWS credentials for the binding + private static ServiceBinding createAwsBinding() { + ServiceBinding binding = mock(ServiceBinding.class); Map credentials = new HashMap<>(); credentials.put("host", "aws.example.com"); credentials.put("region", "us-east-1"); credentials.put("access_key_id", "test-access-key"); credentials.put("secret_access_key", "test-secret-key"); credentials.put("bucket", "test-bucket"); + when(binding.getServiceName()).thenReturn(Optional.of("objectstore")); + when(binding.getCredentials()).thenReturn(credentials); + return binding; + } + @BeforeEach + void setup() { + registration = new Registration(); + configurer = mock(CdsRuntimeConfigurer.class); + CdsRuntime cdsRuntime = mock(CdsRuntime.class); + environment = mock(CdsEnvironment.class); when(configurer.getCdsRuntime()).thenReturn(cdsRuntime); when(cdsRuntime.getEnvironment()).thenReturn(environment); - when(binding.getServiceName()).thenReturn(Optional.of("objectstore")); - when(binding.getCredentials()).thenReturn(credentials); - when(environment.getServiceBindings()).thenReturn(Stream.of(binding)); + when(environment.getProperty("cds.attachments.objectStore.threadPoolSize", Integer.class, 16)) + .thenReturn(16); + awsBinding = createAwsBinding(); + } + + @Test + void testEventHandlersRegistersOSSHandler() { + when(environment.getServiceBindings()).thenReturn(Stream.of(awsBinding)); - // Act registration.eventHandlers(configurer); - // Assert: OSSAttachmentsServiceHandler should be registered verify(configurer).eventHandler(any(OSSAttachmentsServiceHandler.class)); } + + @Test + void testEventHandlersRegistersCleanupHandlerWhenMultitenancyShared() { + when(environment.getServiceBindings()).thenReturn(Stream.of(awsBinding)); + when(environment.getProperty("cds.multitenancy.enabled", Boolean.class, Boolean.FALSE)) + .thenReturn(Boolean.TRUE); + when(environment.getProperty("cds.attachments.objectStore.kind", String.class, null)) + .thenReturn("shared"); + + registration.eventHandlers(configurer); + + verify(configurer, times(2)).eventHandler(any()); + } + + @Test + void testEventHandlersNoBindingDoesNotRegister() { + when(environment.getServiceBindings()).thenReturn(Stream.empty()); + + registration.eventHandlers(configurer); + + verify(configurer, never()).eventHandler(any()); + } + + @Test + void testMtEnabledNonSharedKindRegistersOnlyOSSHandler() { + when(environment.getServiceBindings()).thenReturn(Stream.of(awsBinding)); + when(environment.getProperty("cds.multitenancy.enabled", Boolean.class, Boolean.FALSE)) + .thenReturn(Boolean.TRUE); + when(environment.getProperty("cds.attachments.objectStore.kind", String.class, null)) + .thenReturn("dedicated"); + + registration.eventHandlers(configurer); + + verify(configurer, times(1)).eventHandler(any(OSSAttachmentsServiceHandler.class)); + verify(configurer, times(1)).eventHandler(any()); + } + + @Test + void testMtEnabledNullKindRegistersOnlyOSSHandler() { + when(environment.getServiceBindings()).thenReturn(Stream.of(awsBinding)); + when(environment.getProperty("cds.multitenancy.enabled", Boolean.class, Boolean.FALSE)) + .thenReturn(Boolean.TRUE); + + registration.eventHandlers(configurer); + + verify(configurer, times(1)).eventHandler(any(OSSAttachmentsServiceHandler.class)); + verify(configurer, times(1)).eventHandler(any()); + } + + @Test + void testMtDisabledSharedKindRegistersOnlyOSSHandler() { + when(environment.getServiceBindings()).thenReturn(Stream.of(awsBinding)); + when(environment.getProperty("cds.attachments.objectStore.kind", String.class, null)) + .thenReturn("shared"); + + registration.eventHandlers(configurer); + + verify(configurer, times(1)).eventHandler(any(OSSAttachmentsServiceHandler.class)); + verify(configurer, times(1)).eventHandler(any()); + } } diff --git a/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/handler/OSSAttachmentsServiceHandlerTest.java b/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/handler/OSSAttachmentsServiceHandlerTest.java index 2d973221b..91bc98926 100644 --- a/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/handler/OSSAttachmentsServiceHandlerTest.java +++ b/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/handler/OSSAttachmentsServiceHandlerTest.java @@ -3,39 +3,52 @@ */ package com.sap.cds.feature.attachments.oss.handler; +import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; 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.generated.cds4j.sap.attachments.StatusCode; import com.sap.cds.feature.attachments.oss.client.OSClient; +import com.sap.cds.feature.attachments.oss.client.OSClientFactory; 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.feature.attachments.service.model.servicehandler.AttachmentRestoreEventContext; import com.sap.cds.reflect.CdsEntity; import com.sap.cds.services.ServiceException; +import com.sap.cds.services.request.ModifiableUserInfo; +import com.sap.cds.services.request.UserInfo; import com.sap.cloud.environment.servicebinding.api.ServiceBinding; import java.io.ByteArrayInputStream; import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.time.Instant; import java.util.Base64; import java.util.HashMap; +import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; class OSSAttachmentsServiceHandlerTest { - ExecutorService executor = Executors.newCachedThreadPool(); - @Test - void testRestoreAttachmentCallsSetCompleted() { - // Setup a valid AWS binding for the test + private static final ExecutorService executor = Executors.newCachedThreadPool(); + + private OSClient mockOsClient; + private OSSAttachmentsServiceHandler handler; + + private static ServiceBinding createAwsBinding() { ServiceBinding binding = mock(ServiceBinding.class); HashMap creds = new HashMap<>(); creds.put("host", "aws.example.com"); @@ -44,337 +57,428 @@ void testRestoreAttachmentCallsSetCompleted() { creds.put("secret_access_key", "test-secret-key"); creds.put("bucket", "test-bucket"); when(binding.getCredentials()).thenReturn(creds); - - OSSAttachmentsServiceHandler handler = new OSSAttachmentsServiceHandler(binding, executor); - AttachmentRestoreEventContext context = mock(AttachmentRestoreEventContext.class); - handler.restoreAttachment(context); - verify(context).setCompleted(); + return binding; } - @Test - void testCreateAttachmentCallsOsClientUploadContent() - throws NoSuchFieldException, IllegalAccessException { - OSClient mockOsClient = mock(OSClient.class); - // Mock the handler, but call the real method readAttachment - OSSAttachmentsServiceHandler handler = - mock(OSSAttachmentsServiceHandler.class, CALLS_REAL_METHODS); - AttachmentCreateEventContext context = mock(AttachmentCreateEventContext.class); - - var field = OSSAttachmentsServiceHandler.class.getDeclaredField("osClient"); - field.setAccessible(true); - field.set(handler, mockOsClient); - - String contentId = "doc123"; - String mimeType = "text/plain"; - String fileName = "file.txt"; - - MediaData mockMediaData = mock(MediaData.class); - var mockEntity = mock(com.sap.cds.reflect.CdsEntity.class); - when(mockEntity.getQualifiedName()).thenReturn(fileName); - - InputStream contentStream = new ByteArrayInputStream("test".getBytes()); - - when(context.getAttachmentEntity()).thenReturn(mockEntity); - when(context.getAttachmentIds()).thenReturn(java.util.Map.of("ID", contentId)); - when(context.getData()).thenReturn(mockMediaData); - when(mockMediaData.getContent()).thenReturn(contentStream); - when(mockMediaData.getMimeType()).thenReturn(mimeType); - when(mockOsClient.uploadContent(any(), anyString(), anyString())) - .thenReturn(CompletableFuture.completedFuture(null)); - - when(context.getContentId()).thenReturn(contentId); - - handler.createAttachment(context); - - verify(mockOsClient).uploadContent(contentStream, contentId, mimeType); - verify(context).setIsInternalStored(false); - verify(context).setContentId(contentId); - verify(context).setCompleted(); + private static CdsEntity stubEntity(String name) { + CdsEntity entity = mock(CdsEntity.class); + when(entity.getQualifiedName()).thenReturn(name); + return entity; } - @Test - void testReadAttachmentCallsOsClientReadContent() - throws NoSuchFieldException, IllegalAccessException { - OSClient mockOsClient = mock(OSClient.class); - // Mock the handler, but call the real method readAttachment - OSSAttachmentsServiceHandler handler = - mock(OSSAttachmentsServiceHandler.class, CALLS_REAL_METHODS); - AttachmentReadEventContext context = mock(AttachmentReadEventContext.class); - - var field = OSSAttachmentsServiceHandler.class.getDeclaredField("osClient"); - field.setAccessible(true); - field.set(handler, mockOsClient); - - String contentId = "doc123"; - MediaData mockMediaData = mock(MediaData.class); - - when(context.getContentId()).thenReturn(contentId); - when(context.getData()).thenReturn(mockMediaData); - when(mockOsClient.readContent(contentId)) - .thenReturn(CompletableFuture.completedFuture(new ByteArrayInputStream("test".getBytes()))); - - handler.readAttachment(context); - - verify(mockOsClient).readContent(contentId); - verify(mockMediaData).setContent(any(InputStream.class)); - verify(context).setCompleted(); + /** + * Creates a real {@link AttachmentCreateEventContext} populated with the given values. The only + * mock used is CdsEntity (a model-level concept not creatable without a full model). + */ + private static AttachmentCreateEventContext createContext( + String contentId, String mimeType, String fileName, byte[] content) { + var ctx = AttachmentCreateEventContext.create(); + ctx.setData(MediaData.create()); + ctx.getData().setContent(new ByteArrayInputStream(content)); + ctx.getData().setMimeType(mimeType); + ctx.getData().setFileName(fileName); + ctx.setAttachmentIds(Map.of(Attachments.ID, contentId)); + ctx.setAttachmentEntity(stubEntity("TestEntity")); + return ctx; } - @Test - void testReadAttachmentCallsOsClientReadNullContent() - throws NoSuchFieldException, IllegalAccessException { - OSClient mockOsClient = mock(OSClient.class); - // Mock the handler, but call the real method readAttachment - OSSAttachmentsServiceHandler handler = - mock(OSSAttachmentsServiceHandler.class, CALLS_REAL_METHODS); - AttachmentReadEventContext context = mock(AttachmentReadEventContext.class); - - var field = OSSAttachmentsServiceHandler.class.getDeclaredField("osClient"); - field.setAccessible(true); - field.set(handler, mockOsClient); - - String contentId = "doc123"; - MediaData mockMediaData = mock(MediaData.class); - - when(context.getContentId()).thenReturn(contentId); - when(context.getData()).thenReturn(mockMediaData); - when(mockOsClient.readContent(contentId)).thenReturn(CompletableFuture.completedFuture(null)); - - assertThrows(ServiceException.class, () -> handler.readAttachment(context)); - - verify(mockOsClient).readContent(contentId); - verify(context).setCompleted(); + private static UserInfo userInfoWithTenant(String tenant) { + ModifiableUserInfo userInfo = UserInfo.create(); + userInfo.setTenant(tenant); + return userInfo; } - @Test - void testMarkAttachmentAsDeletedCallsOsClientDeleteContent() - throws NoSuchFieldException, IllegalAccessException { - OSClient mockOsClient = mock(OSClient.class); - // Mock the handler, but call the real method readAttachment - OSSAttachmentsServiceHandler handler = - mock(OSSAttachmentsServiceHandler.class, CALLS_REAL_METHODS); - AttachmentMarkAsDeletedEventContext context = mock(AttachmentMarkAsDeletedEventContext.class); - - var field = OSSAttachmentsServiceHandler.class.getDeclaredField("osClient"); - field.setAccessible(true); - field.set(handler, mockOsClient); + @Nested + class FactoryTests { + + @Test + void testFactoryHandlesInvalidBase64EncodedPrivateKeyData() { + ServiceBinding binding = mock(ServiceBinding.class); + HashMap creds = new HashMap<>(); + creds.put("base64EncodedPrivateKeyData", "not-a-valid-base64-string"); + when(binding.getCredentials()).thenReturn(creds); + + assertThrows( + ObjectStoreServiceException.class, () -> OSClientFactory.create(binding, executor)); + } + + @Test + void testFactoryHandlesValidBase64ButNoGoogleOrGcp() { + String plain = "this is just a dummy string without keywords"; + String base64 = Base64.getEncoder().encodeToString(plain.getBytes(StandardCharsets.UTF_8)); + + ServiceBinding binding = mock(ServiceBinding.class); + HashMap creds = new HashMap<>(); + creds.put("base64EncodedPrivateKeyData", base64); + when(binding.getCredentials()).thenReturn(creds); + + assertThrows( + ObjectStoreServiceException.class, () -> OSClientFactory.create(binding, executor)); + } + + @Test + void testFactoryHandlesInValidBase64() { + ServiceBinding binding = mock(ServiceBinding.class); + HashMap creds = new HashMap<>(); + creds.put("base64EncodedPrivateKeyData", "this is just a dummy string without keywords"); + when(binding.getCredentials()).thenReturn(creds); + + assertThrows( + ObjectStoreServiceException.class, () -> OSClientFactory.create(binding, executor)); + } + + @Test + void testFactoryHandlesNoValidObjectStoreService() { + ServiceBinding binding = mock(ServiceBinding.class); + HashMap creds = new HashMap<>(); + creds.put("someOtherField", "someValue"); + when(binding.getCredentials()).thenReturn(creds); + + assertThrows( + ObjectStoreServiceException.class, () -> OSClientFactory.create(binding, executor)); + } + } - String contentId = "doc123"; - when(context.getContentId()).thenReturn(contentId); - when(mockOsClient.deleteContent(contentId)).thenReturn(CompletableFuture.completedFuture(null)); + @Nested + class SingleTenantOperations { - handler.markAttachmentAsDeleted(context); + @BeforeEach + void setup() { + mockOsClient = mock(OSClient.class); + handler = new OSSAttachmentsServiceHandler(mockOsClient, false, null); + } - verify(mockOsClient).deleteContent(contentId); - verify(context).setCompleted(); - } + @Test + void testRestoreAttachmentCallsSetCompleted() { + var context = AttachmentRestoreEventContext.create(); + context.setRestoreTimestamp(Instant.now()); - @Test - void testConstructorHandlesInvalidBase64EncodedPrivateKeyData() { - // Arrange: ServiceBinding with invalid base64EncodedPrivateKeyData (not valid base64) - ServiceBinding binding = mock(ServiceBinding.class); - HashMap creds = new HashMap<>(); - creds.put("base64EncodedPrivateKeyData", "not-a-valid-base64-string"); - when(binding.getCredentials()).thenReturn(creds); + handler.restoreAttachment(context); - assertThrows( - ObjectStoreServiceException.class, - () -> new OSSAttachmentsServiceHandler(binding, executor)); - } + assertThat(context.isCompleted()).isTrue(); + } - @Test - void testConstructorHandlesValidBase64ButNoGoogleOrGcp() { - String plain = "this is just a dummy string without keywords"; - String base64 = - Base64.getEncoder().encodeToString(plain.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + @Test + void testCreateAttachmentUploadsContent() { + when(mockOsClient.uploadContent(any(), anyString(), anyString())) + .thenReturn(CompletableFuture.completedFuture(null)); - ServiceBinding binding = mock(ServiceBinding.class); - HashMap creds = new HashMap<>(); - creds.put("base64EncodedPrivateKeyData", base64); - when(binding.getCredentials()).thenReturn(creds); + var context = createContext("doc123", "text/plain", "file.txt", "test".getBytes()); - assertThrows( - ObjectStoreServiceException.class, - () -> new OSSAttachmentsServiceHandler(binding, executor)); - } + handler.createAttachment(context); - @Test - void testConstructorHandlesInValidBase64() { - ServiceBinding binding = mock(ServiceBinding.class); - HashMap creds = new HashMap<>(); - creds.put("base64EncodedPrivateKeyData", "this is just a dummy string without keywords"); - when(binding.getCredentials()).thenReturn(creds); + verify(mockOsClient).uploadContent(any(InputStream.class), eq("doc123"), eq("text/plain")); + assertThat(context.getIsInternalStored()).isFalse(); + assertThat(context.getContentId()).isEqualTo("doc123"); + assertThat(context.getData().getStatus()).isEqualTo(StatusCode.SCANNING); + assertThat(context.isCompleted()).isTrue(); + } - assertThrows( - ObjectStoreServiceException.class, - () -> new OSSAttachmentsServiceHandler(binding, executor)); - } + @Test + void testReadAttachmentReadsContent() { + when(mockOsClient.readContent("doc123")) + .thenReturn( + CompletableFuture.completedFuture(new ByteArrayInputStream("test".getBytes()))); - @Test - void testConstructorHandlesNoValidObjectStoreService() { - // Arrange: ServiceBinding with no valid object store credentials - ServiceBinding binding = mock(ServiceBinding.class); - HashMap creds = new HashMap<>(); - // No host, container_uri, or base64EncodedPrivateKeyData - creds.put("someOtherField", "someValue"); - when(binding.getCredentials()).thenReturn(creds); + var context = AttachmentReadEventContext.create(); + context.setContentId("doc123"); + context.setData(MediaData.create()); - assertThrows( - ObjectStoreServiceException.class, - () -> new OSSAttachmentsServiceHandler(binding, executor)); - } + handler.readAttachment(context); - // Helper method to setup common mocks for createAttachment exception tests - private AttachmentCreateEventContext setupCreateAttachmentContext( - OSClient mockOsClient, OSSAttachmentsServiceHandler handler, Exception exceptionToThrow) - throws NoSuchFieldException, - IllegalAccessException, - InterruptedException, - ExecutionException { - - var field = OSSAttachmentsServiceHandler.class.getDeclaredField("osClient"); - field.setAccessible(true); - field.set(handler, mockOsClient); - - AttachmentCreateEventContext context = mock(AttachmentCreateEventContext.class); - MediaData mockMediaData = mock(MediaData.class); - CdsEntity mockEntity = mock(CdsEntity.class); - HashMap attachmentIds = new HashMap<>(); - attachmentIds.put("ID", "test-id"); - - when(context.getAttachmentIds()).thenReturn(attachmentIds); - when(context.getData()).thenReturn(mockMediaData); - when(context.getAttachmentEntity()).thenReturn(mockEntity); - when(mockEntity.getQualifiedName()).thenReturn("TestEntity"); - when(mockMediaData.getFileName()).thenReturn("test.txt"); - when(mockMediaData.getContent()).thenReturn(new ByteArrayInputStream("test".getBytes())); - when(mockMediaData.getMimeType()).thenReturn("text/plain"); - - @SuppressWarnings("unchecked") - CompletableFuture future = mock(CompletableFuture.class); - when(mockOsClient.uploadContent(any(InputStream.class), anyString(), anyString())) - .thenReturn(future); - when(future.get()).thenThrow(exceptionToThrow); - - return context; - } + verify(mockOsClient).readContent("doc123"); + assertThat(context.getData().getContent()).isNotNull(); + assertThat(context.isCompleted()).isTrue(); + } - @Test - void testCreateAttachmentExceptionHandling() - throws NoSuchFieldException, - IllegalAccessException, - InterruptedException, - ExecutionException { - OSClient mockOsClient = mock(OSClient.class); - OSSAttachmentsServiceHandler handler = - mock(OSSAttachmentsServiceHandler.class, CALLS_REAL_METHODS); - - // Test InterruptedException - AttachmentCreateEventContext context1 = - setupCreateAttachmentContext( - mockOsClient, handler, new InterruptedException("Thread interrupted")); - assertThrows(ServiceException.class, () -> handler.createAttachment(context1)); - verify(context1).setCompleted(); - - // Test ObjectStoreServiceException - AttachmentCreateEventContext context2 = - setupCreateAttachmentContext( - mockOsClient, handler, new ObjectStoreServiceException("Upload failed")); - assertThrows(ServiceException.class, () -> handler.createAttachment(context2)); - verify(context2).setCompleted(); - - // Test ExecutionException - AttachmentCreateEventContext context3 = - setupCreateAttachmentContext( - mockOsClient, handler, new ExecutionException("Upload failed", new RuntimeException())); - assertThrows(ServiceException.class, () -> handler.createAttachment(context3)); - verify(context3).setCompleted(); - } + @Test + void testReadAttachmentWithNullContentThrows() { + when(mockOsClient.readContent("doc123")).thenReturn(CompletableFuture.completedFuture(null)); - // Helper method to setup common mocks for markAttachmentAsDeleted exception tests - private AttachmentMarkAsDeletedEventContext setupMarkAsDeletedContext( - OSClient mockOsClient, OSSAttachmentsServiceHandler handler, Exception exceptionToThrow) - throws NoSuchFieldException, - IllegalAccessException, - InterruptedException, - ExecutionException { + var context = AttachmentReadEventContext.create(); + context.setContentId("doc123"); + context.setData(MediaData.create()); - var field = OSSAttachmentsServiceHandler.class.getDeclaredField("osClient"); - field.setAccessible(true); - field.set(handler, mockOsClient); + assertThrows(ServiceException.class, () -> handler.readAttachment(context)); + assertThat(context.isCompleted()).isTrue(); + } - AttachmentMarkAsDeletedEventContext context = mock(AttachmentMarkAsDeletedEventContext.class); - String contentId = "test-content-id"; + @Test + void testMarkAttachmentAsDeletedDeletesContent() { + when(mockOsClient.deleteContent("doc123")) + .thenReturn(CompletableFuture.completedFuture(null)); - when(context.getContentId()).thenReturn(contentId); + var context = AttachmentMarkAsDeletedEventContext.create(); + context.setContentId("doc123"); - @SuppressWarnings("unchecked") - CompletableFuture future = mock(CompletableFuture.class); - when(mockOsClient.deleteContent(contentId)).thenReturn(future); - when(future.get()).thenThrow(exceptionToThrow); + handler.markAttachmentAsDeleted(context); - return context; + verify(mockOsClient).deleteContent("doc123"); + assertThat(context.isCompleted()).isTrue(); + } } - @Test - void testMarkAttachmentAsDeletedExceptionHandling() - throws NoSuchFieldException, - IllegalAccessException, - InterruptedException, - ExecutionException { - OSClient mockOsClient = mock(OSClient.class); - OSSAttachmentsServiceHandler handler = - mock(OSSAttachmentsServiceHandler.class, CALLS_REAL_METHODS); - - // Test InterruptedException - AttachmentMarkAsDeletedEventContext context1 = - setupMarkAsDeletedContext( - mockOsClient, handler, new InterruptedException("Thread interrupted")); - assertThrows(ServiceException.class, () -> handler.markAttachmentAsDeleted(context1)); - verify(context1).setCompleted(); - - // Test ObjectStoreServiceException - AttachmentMarkAsDeletedEventContext context2 = - setupMarkAsDeletedContext( - mockOsClient, handler, new ObjectStoreServiceException("Delete failed")); - assertThrows(ServiceException.class, () -> handler.markAttachmentAsDeleted(context2)); - verify(context2).setCompleted(); - - // Test ExecutionException - AttachmentMarkAsDeletedEventContext context3 = - setupMarkAsDeletedContext( - mockOsClient, handler, new ExecutionException("Delete failed", new RuntimeException())); - assertThrows(ServiceException.class, () -> handler.markAttachmentAsDeleted(context3)); - verify(context3).setCompleted(); + @Nested + class ExceptionHandling { + + @BeforeEach + void setup() { + mockOsClient = mock(OSClient.class); + handler = new OSSAttachmentsServiceHandler(mockOsClient, false, null); + } + + @Test + void testCreateAttachmentHandlesInterruptedException() throws Exception { + var context = createContextForUploadException(new InterruptedException("Thread interrupted")); + assertThrows(ServiceException.class, () -> handler.createAttachment(context)); + assertThat(context.isCompleted()).isTrue(); + } + + @Test + void testCreateAttachmentHandlesObjectStoreServiceException() throws Exception { + var context = + createContextForUploadException(new ObjectStoreServiceException("Upload failed")); + assertThrows(ServiceException.class, () -> handler.createAttachment(context)); + assertThat(context.isCompleted()).isTrue(); + } + + @Test + void testCreateAttachmentHandlesExecutionException() throws Exception { + var context = + createContextForUploadException( + new ExecutionException("Upload failed", new RuntimeException())); + assertThrows(ServiceException.class, () -> handler.createAttachment(context)); + assertThat(context.isCompleted()).isTrue(); + } + + @Test + void testMarkAsDeletedHandlesInterruptedException() throws Exception { + var context = createContextForDeleteException(new InterruptedException("Thread interrupted")); + assertThrows(ServiceException.class, () -> handler.markAttachmentAsDeleted(context)); + assertThat(context.isCompleted()).isTrue(); + } + + @Test + void testMarkAsDeletedHandlesObjectStoreServiceException() throws Exception { + var context = + createContextForDeleteException(new ObjectStoreServiceException("Delete failed")); + assertThrows(ServiceException.class, () -> handler.markAttachmentAsDeleted(context)); + assertThat(context.isCompleted()).isTrue(); + } + + @Test + void testMarkAsDeletedHandlesExecutionException() throws Exception { + var context = + createContextForDeleteException( + new ExecutionException("Delete failed", new RuntimeException())); + assertThrows(ServiceException.class, () -> handler.markAttachmentAsDeleted(context)); + assertThat(context.isCompleted()).isTrue(); + } + + @Test + void testReadAttachmentHandlesInterruptedException() throws Exception { + var context = createContextForReadException(new InterruptedException("Thread interrupted")); + assertThrows(ServiceException.class, () -> handler.readAttachment(context)); + assertThat(context.isCompleted()).isTrue(); + } + + @Test + void testReadAttachmentHandlesExecutionException() throws Exception { + var context = + createContextForReadException(new ExecutionException("failed", new RuntimeException())); + assertThrows(ServiceException.class, () -> handler.readAttachment(context)); + assertThat(context.isCompleted()).isTrue(); + } + + private AttachmentCreateEventContext createContextForUploadException(Exception exception) + throws Exception { + @SuppressWarnings("unchecked") + CompletableFuture future = mock(CompletableFuture.class); + when(mockOsClient.uploadContent(any(InputStream.class), anyString(), anyString())) + .thenReturn(future); + when(future.get()).thenThrow(exception); + + return createContext("test-id", "text/plain", "test.txt", "test".getBytes()); + } + + private AttachmentMarkAsDeletedEventContext createContextForDeleteException(Exception exception) + throws Exception { + @SuppressWarnings("unchecked") + CompletableFuture future = mock(CompletableFuture.class); + when(mockOsClient.deleteContent("test-content-id")).thenReturn(future); + when(future.get()).thenThrow(exception); + + var context = AttachmentMarkAsDeletedEventContext.create(); + context.setContentId("test-content-id"); + return context; + } + + private AttachmentReadEventContext createContextForReadException(Exception exception) + throws Exception { + @SuppressWarnings("unchecked") + CompletableFuture future = mock(CompletableFuture.class); + when(mockOsClient.readContent("doc123")).thenReturn(future); + when(future.get()).thenThrow(exception); + + var context = AttachmentReadEventContext.create(); + context.setContentId("doc123"); + context.setData(MediaData.create()); + return context; + } } - @Test - void testReadAttachmentHandlesInterruptedException() - throws NoSuchFieldException, - IllegalAccessException, - InterruptedException, - ExecutionException { - OSClient mockOsClient = mock(OSClient.class); - OSSAttachmentsServiceHandler handler = - mock(OSSAttachmentsServiceHandler.class, CALLS_REAL_METHODS); - AttachmentReadEventContext context = mock(AttachmentReadEventContext.class); - - var field = OSSAttachmentsServiceHandler.class.getDeclaredField("osClient"); - field.setAccessible(true); - field.set(handler, mockOsClient); - - String contentId = "doc123"; - MediaData mockMediaData = mock(MediaData.class); - - when(context.getContentId()).thenReturn(contentId); - when(context.getData()).thenReturn(mockMediaData); - - @SuppressWarnings("unchecked") - CompletableFuture future = mock(CompletableFuture.class); - when(mockOsClient.readContent(contentId)).thenReturn(future); - when(future.get()).thenThrow(new InterruptedException("Thread interrupted")); - - assertThrows(ServiceException.class, () -> handler.readAttachment(context)); - verify(context).setCompleted(); + @Nested + class MultitenancyTests { + + @BeforeEach + void setup() { + mockOsClient = mock(OSClient.class); + handler = new OSSAttachmentsServiceHandler(mockOsClient, true, "shared"); + } + + @Test + void testCreateAttachmentWithMultitenancyBuildsObjectKey() { + when(mockOsClient.uploadContent(any(), anyString(), anyString())) + .thenReturn(CompletableFuture.completedFuture(null)); + + // For multitenancy, getUserInfo() requires a RequestContext, so we mock + // the event context to provide tenant info + CdsEntity entity = stubEntity("TestEntity"); + UserInfo userInfo = userInfoWithTenant("myTenant"); + MediaData data = MediaData.create(); + data.setContent(new ByteArrayInputStream("test".getBytes())); + data.setMimeType("text/plain"); + data.setFileName("file.txt"); + + AttachmentCreateEventContext context = mock(AttachmentCreateEventContext.class); + when(context.getAttachmentEntity()).thenReturn(entity); + when(context.getAttachmentIds()).thenReturn(Map.of(Attachments.ID, "content123")); + when(context.getData()).thenReturn(data); + when(context.getUserInfo()).thenReturn(userInfo); + + handler.createAttachment(context); + + verify(mockOsClient).uploadContent(any(), eq("myTenant/content123"), anyString()); + } + + @Test + void testReadAttachmentWithMultitenancyBuildsObjectKey() { + when(mockOsClient.readContent("myTenant/content123")) + .thenReturn( + CompletableFuture.completedFuture(new ByteArrayInputStream("test".getBytes()))); + + AttachmentReadEventContext context = mock(AttachmentReadEventContext.class); + when(context.getContentId()).thenReturn("content123"); + when(context.getData()).thenReturn(MediaData.create()); + when(context.getUserInfo()).thenReturn(userInfoWithTenant("myTenant")); + + handler.readAttachment(context); + + verify(mockOsClient).readContent("myTenant/content123"); + } + + @Test + void testMarkAsDeletedWithMultitenancyBuildsObjectKey() { + when(mockOsClient.deleteContent("myTenant/content123")) + .thenReturn(CompletableFuture.completedFuture(null)); + + AttachmentMarkAsDeletedEventContext context = mock(AttachmentMarkAsDeletedEventContext.class); + when(context.getContentId()).thenReturn("content123"); + when(context.getUserInfo()).thenReturn(userInfoWithTenant("myTenant")); + + handler.markAttachmentAsDeleted(context); + + verify(mockOsClient).deleteContent("myTenant/content123"); + } + + @Test + void testMultitenancyWithNullTenantThrows() { + AttachmentReadEventContext context = mock(AttachmentReadEventContext.class); + when(context.getContentId()).thenReturn("content123"); + when(context.getUserInfo()).thenReturn(userInfoWithTenant(null)); + + assertThrows(ServiceException.class, () -> handler.readAttachment(context)); + } + + @Test + void testValidateTenantIdWithSlashThrows() { + AttachmentReadEventContext context = mock(AttachmentReadEventContext.class); + when(context.getContentId()).thenReturn("content123"); + when(context.getUserInfo()).thenReturn(userInfoWithTenant("tenant/evil")); + + assertThrows(ServiceException.class, () -> handler.readAttachment(context)); + } + + @Test + void testValidateTenantIdWithBackslashThrows() { + AttachmentReadEventContext context = mock(AttachmentReadEventContext.class); + when(context.getContentId()).thenReturn("content123"); + when(context.getUserInfo()).thenReturn(userInfoWithTenant("tenant\\evil")); + + assertThrows(ServiceException.class, () -> handler.readAttachment(context)); + } + + @Test + void testValidateTenantIdWithDotsThrows() { + AttachmentReadEventContext context = mock(AttachmentReadEventContext.class); + when(context.getContentId()).thenReturn("content123"); + when(context.getUserInfo()).thenReturn(userInfoWithTenant("..evil")); + + assertThrows(ServiceException.class, () -> handler.readAttachment(context)); + } + + @Test + void testValidateEmptyTenantIdThrows() { + AttachmentReadEventContext context = mock(AttachmentReadEventContext.class); + when(context.getContentId()).thenReturn("content123"); + when(context.getUserInfo()).thenReturn(userInfoWithTenant("")); + + assertThrows(ServiceException.class, () -> handler.readAttachment(context)); + } + + @Test + void testValidateContentIdWithSlashThrows() { + AttachmentReadEventContext context = mock(AttachmentReadEventContext.class); + when(context.getContentId()).thenReturn("content/evil"); + when(context.getUserInfo()).thenReturn(userInfoWithTenant("validTenant")); + + assertThrows(ServiceException.class, () -> handler.readAttachment(context)); + } + + @Test + void testValidateContentIdWithNullThrows() { + AttachmentReadEventContext context = mock(AttachmentReadEventContext.class); + when(context.getContentId()).thenReturn(null); + when(context.getUserInfo()).thenReturn(userInfoWithTenant("validTenant")); + + assertThrows(ServiceException.class, () -> handler.readAttachment(context)); + } + + @Test + void testValidateContentIdWithBackslashThrows() { + AttachmentReadEventContext context = mock(AttachmentReadEventContext.class); + when(context.getContentId()).thenReturn("content\\evil"); + when(context.getUserInfo()).thenReturn(userInfoWithTenant("validTenant")); + + assertThrows(ServiceException.class, () -> handler.readAttachment(context)); + } + + @Test + void testValidateContentIdWithDotsThrows() { + AttachmentReadEventContext context = mock(AttachmentReadEventContext.class); + when(context.getContentId()).thenReturn("..evil"); + when(context.getUserInfo()).thenReturn(userInfoWithTenant("validTenant")); + + assertThrows(ServiceException.class, () -> handler.readAttachment(context)); + } + + @Test + void testValidateEmptyContentIdThrows() { + AttachmentReadEventContext context = mock(AttachmentReadEventContext.class); + when(context.getContentId()).thenReturn(""); + when(context.getUserInfo()).thenReturn(userInfoWithTenant("validTenant")); + + assertThrows(ServiceException.class, () -> handler.readAttachment(context)); + } } } diff --git a/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/handler/OSSAttachmentsServiceHandlerTestUtils.java b/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/handler/OSSAttachmentsServiceHandlerTestUtils.java index 615aba16c..e0fbc6a00 100644 --- a/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/handler/OSSAttachmentsServiceHandlerTestUtils.java +++ b/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/handler/OSSAttachmentsServiceHandlerTestUtils.java @@ -15,6 +15,7 @@ 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.oss.client.OSClient; +import com.sap.cds.feature.attachments.oss.client.OSClientFactory; 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; @@ -39,7 +40,8 @@ public static void testCreateReadDeleteAttachmentFlow( String testFileName = "testFileName-" + System.currentTimeMillis() + ".txt"; String testFileContent = "test"; - OSSAttachmentsServiceHandler handler = new OSSAttachmentsServiceHandler(binding, executor); + OSClient osClient = OSClientFactory.create(binding, executor); + OSSAttachmentsServiceHandler handler = new OSSAttachmentsServiceHandler(osClient, false, null); // Create an AttachmentCreateEventContext with mocked data - to upload a test attachment MediaData createMediaData = mock(MediaData.class); diff --git a/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/handler/TenantCleanupHandlerTest.java b/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/handler/TenantCleanupHandlerTest.java new file mode 100644 index 000000000..818d3f86d --- /dev/null +++ b/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/handler/TenantCleanupHandlerTest.java @@ -0,0 +1,93 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. + */ +package com.sap.cds.feature.attachments.oss.handler; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.sap.cds.feature.attachments.oss.client.OSClient; +import com.sap.cds.services.ServiceException; +import com.sap.cds.services.mt.UnsubscribeEventContext; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class TenantCleanupHandlerTest { + + private OSClient mockOsClient; + private TenantCleanupHandler handler; + + @BeforeEach + void setup() { + mockOsClient = mock(OSClient.class); + handler = new TenantCleanupHandler(mockOsClient); + } + + @Test + void testCleanupTenantDataCallsDeleteByPrefix() throws Exception { + var context = UnsubscribeEventContext.create(); + context.setTenant("tenant1"); + + when(mockOsClient.deleteContentByPrefix("tenant1/")) + .thenReturn(CompletableFuture.completedFuture(null)); + + handler.cleanupTenantData(context); + + verify(mockOsClient).deleteContentByPrefix("tenant1/"); + } + + @Test + void testCleanupTenantDataHandlesInterruptedException() throws Exception { + var context = UnsubscribeEventContext.create(); + context.setTenant("tenant2"); + + @SuppressWarnings("unchecked") + CompletableFuture future = mock(CompletableFuture.class); + when(mockOsClient.deleteContentByPrefix("tenant2/")).thenReturn(future); + when(future.get()).thenThrow(new InterruptedException("interrupted")); + + handler.cleanupTenantData(context); + + verify(mockOsClient).deleteContentByPrefix("tenant2/"); + } + + @Test + void testCleanupTenantDataHandlesRuntimeException() throws Exception { + var context = UnsubscribeEventContext.create(); + context.setTenant("tenant3"); + + when(mockOsClient.deleteContentByPrefix("tenant3/")) + .thenReturn(CompletableFuture.failedFuture(new RuntimeException("fail"))); + + handler.cleanupTenantData(context); + + verify(mockOsClient).deleteContentByPrefix("tenant3/"); + } + + @Test + void testCleanupNullTenantThrowsServiceException() { + var context = UnsubscribeEventContext.create(); + // tenant is null by default + + assertThrows(ServiceException.class, () -> handler.cleanupTenantData(context)); + } + + @Test + void testCleanupHandlesExecutionException() throws Exception { + var context = UnsubscribeEventContext.create(); + context.setTenant("tenant4"); + + @SuppressWarnings("unchecked") + CompletableFuture future = mock(CompletableFuture.class); + when(mockOsClient.deleteContentByPrefix("tenant4/")).thenReturn(future); + when(future.get()).thenThrow(new ExecutionException("fail", new RuntimeException("cause"))); + + handler.cleanupTenantData(context); + + verify(mockOsClient).deleteContentByPrefix("tenant4/"); + } +}