From fabc238472af6e9d834ee5621d57c3645dd8aca5 Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Wed, 8 Apr 2026 17:56:57 +0200 Subject: [PATCH 1/8] add ITests --- integration-tests/mtx-local/srv/pom.xml | 7 + .../MultiTenantAttachmentIsolationTest.java | 2 - .../mt/oss/AbstractMtxOssStorageTest.java | 234 ++++++++++++++++++ .../mt/oss/AwsMtxOssStorageTest.java | 52 ++++ .../mt/oss/AzureMtxOssStorageTest.java | 41 +++ .../mt/oss/GcpMtxOssStorageTest.java | 44 ++++ 6 files changed, 378 insertions(+), 2 deletions(-) create mode 100644 integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/mt/oss/AbstractMtxOssStorageTest.java create mode 100644 integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/mt/oss/AwsMtxOssStorageTest.java create mode 100644 integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/mt/oss/AzureMtxOssStorageTest.java create mode 100644 integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/mt/oss/GcpMtxOssStorageTest.java diff --git a/integration-tests/mtx-local/srv/pom.xml b/integration-tests/mtx-local/srv/pom.xml index 330674cf..c139d0ba 100644 --- a/integration-tests/mtx-local/srv/pom.xml +++ b/integration-tests/mtx-local/srv/pom.xml @@ -60,6 +60,13 @@ spring-security-test test + + + com.sap.cds + cds-feature-attachments-oss + ${revision} + test + 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 index 25d2d533..e31626a4 100644 --- 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 @@ -23,8 +23,6 @@ @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"; diff --git a/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/mt/oss/AbstractMtxOssStorageTest.java b/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/mt/oss/AbstractMtxOssStorageTest.java new file mode 100644 index 00000000..9b416abe --- /dev/null +++ b/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/mt/oss/AbstractMtxOssStorageTest.java @@ -0,0 +1,234 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. + */ +package com.sap.cds.feature.attachments.integrationtests.mt.oss; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.sap.cds.feature.attachments.oss.client.OSClient; +import com.sap.cds.feature.attachments.oss.client.OSClientFactory; +import com.sap.cloud.environment.servicebinding.api.ServiceBinding; +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; + +/** + * Abstract base class for multitenancy OSS storage integration tests. Tests the full palette of + * attachment operations (create, read, delete, update, tenant isolation, tenant cleanup) against a + * real object store with shared-bucket multitenancy enabled. + * + *

Uses the {@link OSClient} directly with tenant-prefixed object keys ({@code tenantId/contentId}) + * to simulate the key structure used by {@link + * com.sap.cds.feature.attachments.oss.handler.OSSAttachmentsServiceHandler} in shared multitenancy + * mode. + * + *

Subclasses provide the cloud-specific {@link ServiceBinding} via {@link #getServiceBinding()}. + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +abstract class AbstractMtxOssStorageTest { + + private static final String TENANT_1 = "test-tenant-1"; + private static final String TENANT_2 = "test-tenant-2"; + private static final String MIME_TYPE = "text/plain"; + + private final String testRunId = String.valueOf(System.currentTimeMillis()); + + private ExecutorService executor; + private OSClient osClient; + private final Set createdObjectKeys = new LinkedHashSet<>(); + + /** + * Returns a {@link ServiceBinding} constructed from environment variables, or {@code null} if the + * required environment variables are not set. + */ + protected abstract ServiceBinding getServiceBinding(); + + /** Returns the cloud provider name for display in skip messages. */ + protected abstract String getProviderName(); + + @BeforeAll + void setUp() { + ServiceBinding binding = getServiceBinding(); + Assumptions.assumeTrue( + binding != null, getProviderName() + " credentials not available — skipping tests"); + + executor = Executors.newCachedThreadPool(); + osClient = OSClientFactory.create(binding, executor); + } + + @AfterAll + void tearDown() { + try { + if (osClient != null) { + for (String key : createdObjectKeys) { + try { + osClient.deleteContent(key).get(); + } catch (Exception ignored) { + // best effort cleanup + } + } + } + } finally { + if (executor != null) { + executor.shutdownNow(); + } + } + } + + @Test + void createAndReadAttachmentForTenant1() throws Exception { + String objectKey = objectKey(TENANT_1, uniqueId("create-t1")); + uploadContent(objectKey, "hello from tenant 1"); + assertThat(downloadContent(objectKey)).isEqualTo("hello from tenant 1"); + } + + @Test + void createAndReadAttachmentForTenant2() throws Exception { + String objectKey = objectKey(TENANT_2, uniqueId("create-t2")); + uploadContent(objectKey, "hello from tenant 2"); + assertThat(downloadContent(objectKey)).isEqualTo("hello from tenant 2"); + } + + @Test + void tenantIsolation_tenant2CannotReadTenant1Attachment() throws Exception { + String contentId = uniqueId("isolation"); + String t1Key = objectKey(TENANT_1, contentId); + String t2Key = objectKey(TENANT_2, contentId); + + uploadContent(t1Key, "secret data"); + + assertThatThrownBy(() -> downloadContent(t2Key)).isInstanceOf(Exception.class); + } + + @Test + void bothTenantsCanReadTheirOwnAttachments() throws Exception { + String contentId = uniqueId("both-tenants"); + String t1Key = objectKey(TENANT_1, contentId); + String t2Key = objectKey(TENANT_2, contentId); + + uploadContent(t1Key, "data for tenant 1"); + uploadContent(t2Key, "data for tenant 2"); + + assertThat(downloadContent(t1Key)).isEqualTo("data for tenant 1"); + assertThat(downloadContent(t2Key)).isEqualTo("data for tenant 2"); + } + + @Test + void deleteAttachmentForTenant1_tenant2Unaffected() throws Exception { + String contentId = uniqueId("delete-isolation"); + String t1Key = objectKey(TENANT_1, contentId); + String t2Key = objectKey(TENANT_2, contentId); + + uploadContent(t1Key, "tenant 1 data"); + uploadContent(t2Key, "tenant 2 data"); + + osClient.deleteContent(t1Key).get(); + createdObjectKeys.remove(t1Key); + + assertThatThrownBy(() -> downloadContent(t1Key)).isInstanceOf(Exception.class); + assertThat(downloadContent(t2Key)).isEqualTo("tenant 2 data"); + } + + @Test + void deleteAttachment_subsequentReadFails() throws Exception { + String objectKey = objectKey(TENANT_1, uniqueId("delete-read")); + + uploadContent(objectKey, "to be deleted"); + + osClient.deleteContent(objectKey).get(); + createdObjectKeys.remove(objectKey); + + assertThatThrownBy(() -> downloadContent(objectKey)).isInstanceOf(Exception.class); + } + + @Test + void updateFlow_createReadDeleteCreateRead() throws Exception { + String objectKey = objectKey(TENANT_1, uniqueId("update-flow")); + + uploadContent(objectKey, "original content"); + assertThat(downloadContent(objectKey)).isEqualTo("original content"); + + osClient.deleteContent(objectKey).get(); + createdObjectKeys.remove(objectKey); + + String newObjectKey = objectKey(TENANT_1, uniqueId("update-flow-v2")); + uploadContent(newObjectKey, "updated content"); + + assertThatThrownBy(() -> downloadContent(objectKey)).isInstanceOf(Exception.class); + assertThat(downloadContent(newObjectKey)).isEqualTo("updated content"); + } + + @Test + void multipleAttachmentsPerTenant() throws Exception { + List objectKeys = new ArrayList<>(); + for (int i = 0; i < 3; i++) { + String objectKey = objectKey(TENANT_1, uniqueId("multi-" + i)); + objectKeys.add(objectKey); + uploadContent(objectKey, "file " + i); + } + + for (int i = 0; i < 3; i++) { + assertThat(downloadContent(objectKeys.get(i))).isEqualTo("file " + i); + } + } + + @Test + void tenantCleanup_deleteByPrefixRemovesAllTenantObjects() throws Exception { + List t1Keys = new ArrayList<>(); + for (int i = 0; i < 3; i++) { + String objectKey = objectKey(TENANT_1, uniqueId("cleanup-t1-" + i)); + t1Keys.add(objectKey); + uploadContent(objectKey, "t1 cleanup data " + i); + } + + String t2Key = objectKey(TENANT_2, uniqueId("cleanup-t2")); + uploadContent(t2Key, "t2 data survives cleanup"); + + osClient.deleteContentByPrefix(TENANT_1 + "/").get(); + createdObjectKeys.removeAll(t1Keys); + + for (String key : t1Keys) { + assertThatThrownBy(() -> downloadContent(key)) + .as("Tenant-1 object should have been deleted by cleanup: " + key) + .isInstanceOf(Exception.class); + } + + assertThat(downloadContent(t2Key)).isEqualTo("t2 data survives cleanup"); + } + + // --- Helper methods --- + + private String uniqueId(String label) { + return "mtx-test-" + label + "-" + testRunId; + } + + private static String objectKey(String tenant, String contentId) { + return tenant + "/" + contentId; + } + + private void uploadContent(String objectKey, String content) throws Exception { + InputStream stream = new ByteArrayInputStream(content.getBytes()); + osClient.uploadContent(stream, objectKey, MIME_TYPE).get(); + createdObjectKeys.add(objectKey); + } + + private String downloadContent(String objectKey) throws Exception { + try (InputStream stream = osClient.readContent(objectKey).get()) { + if (stream == null) { + throw new RuntimeException("Content not found for key: " + objectKey); + } + return new String(stream.readAllBytes()); + } + } +} diff --git a/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/mt/oss/AwsMtxOssStorageTest.java b/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/mt/oss/AwsMtxOssStorageTest.java new file mode 100644 index 00000000..068504f6 --- /dev/null +++ b/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/mt/oss/AwsMtxOssStorageTest.java @@ -0,0 +1,52 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. + */ +package com.sap.cds.feature.attachments.integrationtests.mt.oss; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.sap.cloud.environment.servicebinding.api.ServiceBinding; +import java.util.HashMap; + +/** + * Runs the multitenancy OSS storage integration tests against a real AWS S3 instance. Skipped + * automatically if the required environment variables are not set. + * + *

Required environment variables: {@code AWS_S3_HOST}, {@code AWS_S3_BUCKET}, {@code + * AWS_S3_REGION}, {@code AWS_S3_ACCESS_KEY_ID}, {@code AWS_S3_SECRET_ACCESS_KEY}. + */ +class AwsMtxOssStorageTest extends AbstractMtxOssStorageTest { + + @Override + protected ServiceBinding getServiceBinding() { + String host = System.getenv("AWS_S3_HOST"); + String bucket = System.getenv("AWS_S3_BUCKET"); + String region = System.getenv("AWS_S3_REGION"); + String accessKeyId = System.getenv("AWS_S3_ACCESS_KEY_ID"); + String secretAccessKey = System.getenv("AWS_S3_SECRET_ACCESS_KEY"); + + if (host == null + || bucket == null + || region == null + || accessKeyId == null + || secretAccessKey == null) { + return null; + } + + ServiceBinding binding = mock(ServiceBinding.class); + HashMap creds = new HashMap<>(); + creds.put("host", host); + creds.put("bucket", bucket); + creds.put("region", region); + creds.put("access_key_id", accessKeyId); + creds.put("secret_access_key", secretAccessKey); + when(binding.getCredentials()).thenReturn(creds); + return binding; + } + + @Override + protected String getProviderName() { + return "AWS S3"; + } +} diff --git a/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/mt/oss/AzureMtxOssStorageTest.java b/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/mt/oss/AzureMtxOssStorageTest.java new file mode 100644 index 00000000..1ebe1a35 --- /dev/null +++ b/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/mt/oss/AzureMtxOssStorageTest.java @@ -0,0 +1,41 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. + */ +package com.sap.cds.feature.attachments.integrationtests.mt.oss; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.sap.cloud.environment.servicebinding.api.ServiceBinding; +import java.util.HashMap; + +/** + * Runs the multitenancy OSS storage integration tests against a real Azure Blob Storage instance. + * Skipped automatically if the required environment variables are not set. + * + *

Required environment variables: {@code AZURE_CONTAINER_URI}, {@code AZURE_SAS_TOKEN}. + */ +class AzureMtxOssStorageTest extends AbstractMtxOssStorageTest { + + @Override + protected ServiceBinding getServiceBinding() { + String containerUri = System.getenv("AZURE_CONTAINER_URI"); + String sasToken = System.getenv("AZURE_SAS_TOKEN"); + + if (containerUri == null || sasToken == null) { + return null; + } + + ServiceBinding binding = mock(ServiceBinding.class); + HashMap creds = new HashMap<>(); + creds.put("container_uri", containerUri); + creds.put("sas_token", sasToken); + when(binding.getCredentials()).thenReturn(creds); + return binding; + } + + @Override + protected String getProviderName() { + return "Azure Blob Storage"; + } +} diff --git a/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/mt/oss/GcpMtxOssStorageTest.java b/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/mt/oss/GcpMtxOssStorageTest.java new file mode 100644 index 00000000..017248d7 --- /dev/null +++ b/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/mt/oss/GcpMtxOssStorageTest.java @@ -0,0 +1,44 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. + */ +package com.sap.cds.feature.attachments.integrationtests.mt.oss; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.sap.cloud.environment.servicebinding.api.ServiceBinding; +import java.util.HashMap; + +/** + * Runs the multitenancy OSS storage integration tests against a real Google Cloud Storage instance. + * Skipped automatically if the required environment variables are not set. + * + *

Required environment variables: {@code GS_BUCKET}, {@code GS_PROJECT_ID}, {@code + * GS_BASE_64_ENCODED_PRIVATE_KEY_DATA}. + */ +class GcpMtxOssStorageTest extends AbstractMtxOssStorageTest { + + @Override + protected ServiceBinding getServiceBinding() { + String bucket = System.getenv("GS_BUCKET"); + String projectId = System.getenv("GS_PROJECT_ID"); + String base64EncodedPrivateKeyData = System.getenv("GS_BASE_64_ENCODED_PRIVATE_KEY_DATA"); + + if (bucket == null || projectId == null || base64EncodedPrivateKeyData == null) { + return null; + } + + ServiceBinding binding = mock(ServiceBinding.class); + HashMap creds = new HashMap<>(); + creds.put("bucket", bucket); + creds.put("projectId", projectId); + creds.put("base64EncodedPrivateKeyData", base64EncodedPrivateKeyData); + when(binding.getCredentials()).thenReturn(creds); + return binding; + } + + @Override + protected String getProviderName() { + return "Google Cloud Storage"; + } +} From 74fd8bc6cd8e759d6c43c9a93952b41288245db1 Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Wed, 8 Apr 2026 17:58:25 +0200 Subject: [PATCH 2/8] spotless --- .../integrationtests/mt/oss/AbstractMtxOssStorageTest.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/mt/oss/AbstractMtxOssStorageTest.java b/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/mt/oss/AbstractMtxOssStorageTest.java index 9b416abe..455dc411 100644 --- a/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/mt/oss/AbstractMtxOssStorageTest.java +++ b/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/mt/oss/AbstractMtxOssStorageTest.java @@ -28,8 +28,8 @@ * attachment operations (create, read, delete, update, tenant isolation, tenant cleanup) against a * real object store with shared-bucket multitenancy enabled. * - *

Uses the {@link OSClient} directly with tenant-prefixed object keys ({@code tenantId/contentId}) - * to simulate the key structure used by {@link + *

Uses the {@link OSClient} directly with tenant-prefixed object keys ({@code + * tenantId/contentId}) to simulate the key structure used by {@link * com.sap.cds.feature.attachments.oss.handler.OSSAttachmentsServiceHandler} in shared multitenancy * mode. * From 6df5bf4fe932979773f6ab111c7506573c8955e6 Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Wed, 8 Apr 2026 18:17:55 +0200 Subject: [PATCH 3/8] address suggestions --- integration-tests/mtx-local/srv/pom.xml | 1 + .../integrationtests/mt/oss/AbstractMtxOssStorageTest.java | 7 ++++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/integration-tests/mtx-local/srv/pom.xml b/integration-tests/mtx-local/srv/pom.xml index c139d0ba..a34d3420 100644 --- a/integration-tests/mtx-local/srv/pom.xml +++ b/integration-tests/mtx-local/srv/pom.xml @@ -206,6 +206,7 @@ org.apache.maven.plugins maven-resources-plugin + 3.4.0 copy-cds-models-to-sidecar diff --git a/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/mt/oss/AbstractMtxOssStorageTest.java b/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/mt/oss/AbstractMtxOssStorageTest.java index 455dc411..55cce089 100644 --- a/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/mt/oss/AbstractMtxOssStorageTest.java +++ b/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/mt/oss/AbstractMtxOssStorageTest.java @@ -11,6 +11,7 @@ import com.sap.cloud.environment.servicebinding.api.ServiceBinding; import java.io.ByteArrayInputStream; import java.io.InputStream; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.LinkedHashSet; import java.util.List; @@ -101,7 +102,7 @@ void createAndReadAttachmentForTenant2() throws Exception { } @Test - void tenantIsolation_tenant2CannotReadTenant1Attachment() throws Exception { + void readWithDifferentTenantPrefix_fails() throws Exception { String contentId = uniqueId("isolation"); String t1Key = objectKey(TENANT_1, contentId); String t2Key = objectKey(TENANT_2, contentId); @@ -218,7 +219,7 @@ private static String objectKey(String tenant, String contentId) { } private void uploadContent(String objectKey, String content) throws Exception { - InputStream stream = new ByteArrayInputStream(content.getBytes()); + InputStream stream = new ByteArrayInputStream(content.getBytes(StandardCharsets.UTF_8)); osClient.uploadContent(stream, objectKey, MIME_TYPE).get(); createdObjectKeys.add(objectKey); } @@ -228,7 +229,7 @@ private String downloadContent(String objectKey) throws Exception { if (stream == null) { throw new RuntimeException("Content not found for key: " + objectKey); } - return new String(stream.readAllBytes()); + return new String(stream.readAllBytes(), StandardCharsets.UTF_8); } } } From 86bca988521256a5be7a5e400fa04cc06024c842 Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Thu, 9 Apr 2026 09:50:56 +0200 Subject: [PATCH 4/8] other approach --- integration-tests/mtx-local/.gitignore | 3 +++ .../integrationtests/mt/oss/AbstractMtxOssStorageTest.java | 6 ++---- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/integration-tests/mtx-local/.gitignore b/integration-tests/mtx-local/.gitignore index 41fd9705..bd8d00c2 100644 --- a/integration-tests/mtx-local/.gitignore +++ b/integration-tests/mtx-local/.gitignore @@ -1 +1,4 @@ integration-tests/mtx-local/package-lock.json + +# added by cds +.cdsrc-private.json diff --git a/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/mt/oss/AbstractMtxOssStorageTest.java b/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/mt/oss/AbstractMtxOssStorageTest.java index 55cce089..fcd51303 100644 --- a/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/mt/oss/AbstractMtxOssStorageTest.java +++ b/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/mt/oss/AbstractMtxOssStorageTest.java @@ -177,10 +177,8 @@ void multipleAttachmentsPerTenant() throws Exception { String objectKey = objectKey(TENANT_1, uniqueId("multi-" + i)); objectKeys.add(objectKey); uploadContent(objectKey, "file " + i); - } - - for (int i = 0; i < 3; i++) { - assertThat(downloadContent(objectKeys.get(i))).isEqualTo("file " + i); + // read-back immediately to avoid S3 eventual consistency issues + assertThat(downloadContent(objectKey)).isEqualTo("file " + i); } } From 127198474fabac326e3468f16746bd78f5382638 Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Thu, 9 Apr 2026 10:02:10 +0200 Subject: [PATCH 5/8] fix azure test skipping 404s --- .../mt/oss/AbstractMtxOssStorageTest.java | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/mt/oss/AbstractMtxOssStorageTest.java b/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/mt/oss/AbstractMtxOssStorageTest.java index fcd51303..c5d4a3f2 100644 --- a/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/mt/oss/AbstractMtxOssStorageTest.java +++ b/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/mt/oss/AbstractMtxOssStorageTest.java @@ -184,9 +184,13 @@ void multipleAttachmentsPerTenant() throws Exception { @Test void tenantCleanup_deleteByPrefixRemovesAllTenantObjects() throws Exception { + // Use a dedicated tenant prefix so deleteContentByPrefix only touches objects created here, + // avoiding 404s from blobs already deleted by other tests (Azure lists-then-deletes). + String cleanupTenant = "cleanup-tenant-" + testRunId; + List t1Keys = new ArrayList<>(); for (int i = 0; i < 3; i++) { - String objectKey = objectKey(TENANT_1, uniqueId("cleanup-t1-" + i)); + String objectKey = objectKey(cleanupTenant, uniqueId("cleanup-t1-" + i)); t1Keys.add(objectKey); uploadContent(objectKey, "t1 cleanup data " + i); } @@ -194,12 +198,12 @@ void tenantCleanup_deleteByPrefixRemovesAllTenantObjects() throws Exception { String t2Key = objectKey(TENANT_2, uniqueId("cleanup-t2")); uploadContent(t2Key, "t2 data survives cleanup"); - osClient.deleteContentByPrefix(TENANT_1 + "/").get(); + osClient.deleteContentByPrefix(cleanupTenant + "/").get(); createdObjectKeys.removeAll(t1Keys); for (String key : t1Keys) { assertThatThrownBy(() -> downloadContent(key)) - .as("Tenant-1 object should have been deleted by cleanup: " + key) + .as("Object should have been deleted by cleanup: " + key) .isInstanceOf(Exception.class); } From 4ffecaa25830c5766ca624003d2d5c2f2cf659c9 Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Thu, 9 Apr 2026 13:51:39 +0200 Subject: [PATCH 6/8] feat: Add separate-bucket multitenancy support for OSS attachments When cds.attachments.objectStore.kind=separate, each tenant gets a dedicated object store bucket provisioned via SAP BTP Service Manager. - Introduce OSClientProvider strategy pattern to abstract per-tenant client resolution (SharedOSClientProvider / SeparateOSClientProvider) - Add Service Manager REST client for provisioning instances and bindings - Add tenant lifecycle handlers (subscribe/unsubscribe) for bucket provisioning and cleanup via DeploymentService events - Refactor Registration to support shared, separate, and single-tenant modes with clean code paths --- .../oss/client/OSClientProvider.java | 20 ++ .../oss/client/SharedOSClientProvider.java | 24 ++ .../oss/configuration/Registration.java | 169 +++++++--- .../handler/OSSAttachmentsServiceHandler.java | 27 +- .../oss/multitenancy/CachedOSClient.java | 16 + .../ObjectStoreLifecycleHandler.java | 124 +++++++ .../ObjectStoreSubscribeHandler.java | 26 ++ .../ObjectStoreUnsubscribeHandler.java | 28 ++ .../SeparateOSClientProvider.java | 79 +++++ .../TenantNotProvisionedException.java | 14 + .../sm/ServiceManagerBinding.java | 70 ++++ .../multitenancy/sm/ServiceManagerClient.java | 314 ++++++++++++++++++ .../sm/ServiceManagerCredentials.java | 45 +++ .../sm/ServiceManagerException.java | 16 + .../sm/ServiceManagerTokenProvider.java | 93 ++++++ .../oss/configuration/RegistrationTest.java | 157 ++++++--- .../OSSAttachmentsServiceHandlerTest.java | 7 +- ...OSSAttachmentsServiceHandlerTestUtils.java | 13 +- .../ObjectStoreLifecycleHandlerTest.java | 134 ++++++++ .../ObjectStoreSubscribeHandlerTest.java | 27 ++ .../ObjectStoreUnsubscribeHandlerTest.java | 27 ++ .../SeparateOSClientProviderTest.java | 117 +++++++ .../sm/ServiceManagerBindingTest.java | 80 +++++ .../sm/ServiceManagerClientTest.java | 299 +++++++++++++++++ .../sm/ServiceManagerCredentialsTest.java | 80 +++++ .../sm/ServiceManagerExceptionTest.java | 28 ++ .../sm/ServiceManagerTokenProviderTest.java | 95 ++++++ 27 files changed, 2010 insertions(+), 119 deletions(-) create mode 100644 storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/client/OSClientProvider.java create mode 100644 storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/client/SharedOSClientProvider.java create mode 100644 storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/multitenancy/CachedOSClient.java create mode 100644 storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/multitenancy/ObjectStoreLifecycleHandler.java create mode 100644 storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/multitenancy/ObjectStoreSubscribeHandler.java create mode 100644 storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/multitenancy/ObjectStoreUnsubscribeHandler.java create mode 100644 storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/multitenancy/SeparateOSClientProvider.java create mode 100644 storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/multitenancy/TenantNotProvisionedException.java create mode 100644 storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/multitenancy/sm/ServiceManagerBinding.java create mode 100644 storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/multitenancy/sm/ServiceManagerClient.java create mode 100644 storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/multitenancy/sm/ServiceManagerCredentials.java create mode 100644 storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/multitenancy/sm/ServiceManagerException.java create mode 100644 storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/multitenancy/sm/ServiceManagerTokenProvider.java create mode 100644 storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/multitenancy/ObjectStoreLifecycleHandlerTest.java create mode 100644 storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/multitenancy/ObjectStoreSubscribeHandlerTest.java create mode 100644 storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/multitenancy/ObjectStoreUnsubscribeHandlerTest.java create mode 100644 storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/multitenancy/SeparateOSClientProviderTest.java create mode 100644 storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/multitenancy/sm/ServiceManagerBindingTest.java create mode 100644 storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/multitenancy/sm/ServiceManagerClientTest.java create mode 100644 storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/multitenancy/sm/ServiceManagerCredentialsTest.java create mode 100644 storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/multitenancy/sm/ServiceManagerExceptionTest.java create mode 100644 storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/multitenancy/sm/ServiceManagerTokenProviderTest.java diff --git a/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/client/OSClientProvider.java b/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/client/OSClientProvider.java new file mode 100644 index 00000000..419fc301 --- /dev/null +++ b/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/client/OSClientProvider.java @@ -0,0 +1,20 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. + */ +package com.sap.cds.feature.attachments.oss.client; + +/** + * Strategy interface for resolving the {@link OSClient} to use for a given tenant. In shared mode, + * a single client is returned for all tenants. In separate mode, a per-tenant client is resolved + * from the cache or provisioned via Service Manager. + */ +public interface OSClientProvider { + + /** + * Returns the {@link OSClient} to use for the given tenant. + * + * @param tenantId the tenant identifier, or {@code null} for single-tenant deployments + * @return the appropriate {@link OSClient} instance + */ + OSClient getClient(String tenantId); +} diff --git a/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/client/SharedOSClientProvider.java b/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/client/SharedOSClientProvider.java new file mode 100644 index 00000000..5aedffae --- /dev/null +++ b/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/client/SharedOSClientProvider.java @@ -0,0 +1,24 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. + */ +package com.sap.cds.feature.attachments.oss.client; + +import java.util.Objects; + +/** + * An {@link OSClientProvider} that always returns the same shared {@link OSClient} regardless of + * tenant. Used for single-tenant deployments and shared-bucket multitenancy mode. + */ +public class SharedOSClientProvider implements OSClientProvider { + + private final OSClient osClient; + + public SharedOSClientProvider(OSClient osClient) { + this.osClient = Objects.requireNonNull(osClient, "osClient must not be null"); + } + + @Override + public OSClient getClient(String tenantId) { + return osClient; + } +} 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 3fbca05e..b57a32a2 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 @@ -5,12 +5,22 @@ 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.client.SharedOSClientProvider; import com.sap.cds.feature.attachments.oss.handler.OSSAttachmentsServiceHandler; import com.sap.cds.feature.attachments.oss.handler.TenantCleanupHandler; +import com.sap.cds.feature.attachments.oss.multitenancy.ObjectStoreLifecycleHandler; +import com.sap.cds.feature.attachments.oss.multitenancy.ObjectStoreSubscribeHandler; +import com.sap.cds.feature.attachments.oss.multitenancy.ObjectStoreUnsubscribeHandler; +import com.sap.cds.feature.attachments.oss.multitenancy.SeparateOSClientProvider; +import com.sap.cds.feature.attachments.oss.multitenancy.sm.ServiceManagerClient; +import com.sap.cds.feature.attachments.oss.multitenancy.sm.ServiceManagerCredentials; +import com.sap.cds.feature.attachments.oss.multitenancy.sm.ServiceManagerTokenProvider; import com.sap.cds.services.environment.CdsEnvironment; import com.sap.cds.services.runtime.CdsRuntimeConfiguration; import com.sap.cds.services.runtime.CdsRuntimeConfigurer; import com.sap.cloud.environment.servicebinding.api.ServiceBinding; +import java.net.http.HttpClient; +import java.time.Duration; import java.util.Optional; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -25,63 +35,110 @@ public class Registration implements CdsRuntimeConfiguration { @Override public void eventHandlers(CdsRuntimeConfigurer configurer) { CdsEnvironment env = configurer.getCdsRuntime().getEnvironment(); - Optional bindingOpt = getOSBinding(env); - if (bindingOpt.isPresent()) { - 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."); - } + boolean multitenancyEnabled = isMultitenancyEnabled(env); + String objectStoreKind = getObjectStoreKind(env); + + if (multitenancyEnabled && "separate".equals(objectStoreKind)) { + registerSeparateMode(configurer, env); } else { + registerSharedOrSingleTenantMode(configurer, env, multitenancyEnabled, objectStoreKind); + } + } + + private void registerSeparateMode(CdsRuntimeConfigurer configurer, CdsEnvironment env) { + Optional smBindingOpt = getServiceManagerBinding(env); + if (smBindingOpt.isEmpty()) { + logger.error( + "Separate-bucket multitenancy requires a 'service-manager' service binding, but none was" + + " found. OSS Attachments will not be registered."); + return; + } + + ExecutorService executor = createExecutor(env); + ServiceManagerCredentials smCreds = + ServiceManagerCredentials.fromServiceBinding(smBindingOpt.get()); + HttpClient httpClient = HttpClient.newHttpClient(); + ServiceManagerTokenProvider tokenProvider = + new ServiceManagerTokenProvider(smCreds, httpClient); + ServiceManagerClient smClient = new ServiceManagerClient(smCreds, tokenProvider, httpClient); + + int ttlHours = + env.getProperty( + "cds.attachments.objectStore.separate.credentialTtlHours", Integer.class, 11); + SeparateOSClientProvider clientProvider = + new SeparateOSClientProvider(smClient, executor, Duration.ofHours(ttlHours)); + + OSSAttachmentsServiceHandler handler = + new OSSAttachmentsServiceHandler(clientProvider, true, "separate"); + configurer.eventHandler(handler); + + ObjectStoreLifecycleHandler lifecycleHandler = + new ObjectStoreLifecycleHandler(smClient, clientProvider, executor); + configurer.eventHandler(new ObjectStoreSubscribeHandler(lifecycleHandler)); + configurer.eventHandler(new ObjectStoreUnsubscribeHandler(lifecycleHandler)); + + logger.info( + "Registered OSS Attachments Service Handler with separate-bucket multitenancy mode."); + } + + private void registerSharedOrSingleTenantMode( + CdsRuntimeConfigurer configurer, + CdsEnvironment env, + boolean multitenancyEnabled, + String objectStoreKind) { + Optional bindingOpt = getOSBinding(env); + if (bindingOpt.isEmpty()) { logger.warn( - "No service binding to Object Store Service found, hence the OSS Attachments Service Handler is not connected!"); + "No service binding to Object Store Service found, hence the OSS Attachments Service" + + " Handler is not connected!"); + return; } + + ExecutorService executor = createExecutor(env); + OSClient osClient = OSClientFactory.create(bindingOpt.get(), executor); + var osClientProvider = new SharedOSClientProvider(osClient); + OSSAttachmentsServiceHandler handler = + new OSSAttachmentsServiceHandler(osClientProvider, 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."); + } + } + + private ExecutorService createExecutor(CdsEnvironment env) { + 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(); + } + })); + return executor; } - /** - * Retrieves the {@link ServiceBinding} for the object store service from the given {@link - * CdsEnvironment}. - * - * @param environment the {@link CdsEnvironment} to retrieve the service binding from - * @return an {@link Optional} containing the {@link ServiceBinding} for "objectstore" if - * available, or {@link Optional#empty()} if not found - */ private static Optional getOSBinding(CdsEnvironment environment) { return environment .getServiceBindings() @@ -89,6 +146,14 @@ private static Optional getOSBinding(CdsEnvironment environment) .findFirst(); } + private static Optional getServiceManagerBinding(CdsEnvironment environment) { + return environment + .getServiceBindings() + .filter( + b -> b.getServiceName().map(name -> name.equals("service-manager")).orElse(false)) + .findFirst(); + } + private static boolean isMultitenancyEnabled(CdsEnvironment env) { return Boolean.TRUE.equals( env.getProperty("cds.multitenancy.enabled", Boolean.class, Boolean.FALSE)); 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 cdc4e6bb..c37ca836 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 @@ -7,6 +7,7 @@ import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.MediaData; import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.StatusCode; import com.sap.cds.feature.attachments.oss.client.OSClient; +import com.sap.cds.feature.attachments.oss.client.OSClientProvider; 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; @@ -31,23 +32,20 @@ public class OSSAttachmentsServiceHandler implements EventHandler { private static final Logger logger = LoggerFactory.getLogger(OSSAttachmentsServiceHandler.class); - private final OSClient osClient; + private final OSClientProvider osClientProvider; private final boolean multitenancyEnabled; private final String objectStoreKind; /** - * Creates a new OSSAttachmentsServiceHandler with the given {@link OSClient}. + * Creates a new OSSAttachmentsServiceHandler with the given {@link OSClientProvider}. * - *

Use {@link com.sap.cds.feature.attachments.oss.client.OSClientFactory#create - * OSClientFactory.create()} to obtain an {@link OSClient} from a service binding. - * - * @param osClient the object store client for storage operations + * @param osClientProvider the provider for resolving the object store client per tenant * @param multitenancyEnabled whether multitenancy is enabled - * @param objectStoreKind the object store kind (e.g. "shared") + * @param objectStoreKind the object store kind (e.g. "shared", "separate") */ public OSSAttachmentsServiceHandler( - OSClient osClient, boolean multitenancyEnabled, String objectStoreKind) { - this.osClient = osClient; + OSClientProvider osClientProvider, boolean multitenancyEnabled, String objectStoreKind) { + this.osClientProvider = osClientProvider; this.multitenancyEnabled = multitenancyEnabled; this.objectStoreKind = objectStoreKind; } @@ -64,6 +62,7 @@ void createAttachment(AttachmentCreateEventContext context) { String objectKey = buildObjectKey(context, contentId); try { + OSClient osClient = resolveClient(context); osClient.uploadContent(data.getContent(), objectKey, data.getMimeType()).get(); logger.info("Uploaded file {}", fileName); context.getData().setStatus(StatusCode.SCANNING); @@ -88,6 +87,7 @@ void markAttachmentAsDeleted(AttachmentMarkAsDeletedEventContext context) { try { String objectKey = buildObjectKey(context, context.getContentId()); + OSClient osClient = resolveClient(context); osClient.deleteContent(objectKey).get(); } catch (InterruptedException ex) { Thread.currentThread().interrupt(); @@ -118,6 +118,7 @@ void readAttachment(AttachmentReadEventContext context) { context.getContentId()); try { String objectKey = buildObjectKey(context, context.getContentId()); + OSClient osClient = resolveClient(context); Future future = osClient.readContent(objectKey); InputStream inputStream = future.get(); // Wait for the content to be read if (inputStream != null) { @@ -153,6 +154,14 @@ private String buildObjectKey(EventContext context, String contentId) { return contentId; } + private OSClient resolveClient(EventContext context) { + if (multitenancyEnabled) { + String tenantId = getTenant(context); + return osClientProvider.getClient(tenantId); + } + return osClientProvider.getClient(null); + } + private String getTenant(EventContext context) { String tenant = context.getUserInfo().getTenant(); if (tenant == null) { diff --git a/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/multitenancy/CachedOSClient.java b/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/multitenancy/CachedOSClient.java new file mode 100644 index 00000000..aa18a01e --- /dev/null +++ b/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/multitenancy/CachedOSClient.java @@ -0,0 +1,16 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. + */ +package com.sap.cds.feature.attachments.oss.multitenancy; + +import com.sap.cds.feature.attachments.oss.client.OSClient; +import java.time.Duration; +import java.time.Instant; + +/** A cached {@link OSClient} with creation timestamp for TTL-based expiry. */ +record CachedOSClient(OSClient client, Instant createdAt) { + + boolean isExpired(Duration ttl) { + return Duration.between(createdAt, Instant.now()).compareTo(ttl) > 0; + } +} diff --git a/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/multitenancy/ObjectStoreLifecycleHandler.java b/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/multitenancy/ObjectStoreLifecycleHandler.java new file mode 100644 index 00000000..ef85925d --- /dev/null +++ b/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/multitenancy/ObjectStoreLifecycleHandler.java @@ -0,0 +1,124 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. + */ +package com.sap.cds.feature.attachments.oss.multitenancy; + +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.multitenancy.sm.ServiceManagerBinding; +import com.sap.cds.feature.attachments.oss.multitenancy.sm.ServiceManagerClient; +import com.sap.cds.feature.attachments.oss.multitenancy.sm.ServiceManagerClient.ServiceManagerBindingResult; +import com.sap.cds.feature.attachments.oss.multitenancy.sm.ServiceManagerException; +import java.util.concurrent.ExecutorService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Business logic for tenant lifecycle management in separate-bucket multitenancy mode. Handles + * provisioning (subscribe) and deprovisioning (unsubscribe) of per-tenant object store buckets via + * Service Manager. + */ +public class ObjectStoreLifecycleHandler { + + private static final Logger logger = LoggerFactory.getLogger(ObjectStoreLifecycleHandler.class); + + private final ServiceManagerClient smClient; + private final SeparateOSClientProvider clientProvider; + private final ExecutorService executor; + + public ObjectStoreLifecycleHandler( + ServiceManagerClient smClient, + SeparateOSClientProvider clientProvider, + ExecutorService executor) { + this.smClient = smClient; + this.clientProvider = clientProvider; + this.executor = executor; + } + + /** + * Provisions an object store bucket for a new tenant. Idempotent — if a binding already exists, + * the cache is simply warmed. + */ + public void onTenantSubscribe(String tenantId) { + logger.info("Provisioning object store for tenant {}", tenantId); + + // Idempotency: check if binding already exists + var existingBinding = smClient.getBinding(tenantId); + if (existingBinding.isPresent()) { + logger.info("Binding already exists for tenant {}, warming cache", tenantId); + warmCache(tenantId, existingBinding.get()); + return; + } + + String offeringId = smClient.getOfferingId(); + String planId = smClient.getPlanId(offeringId); + + String instanceId = smClient.createInstance(tenantId, planId); + try { + ServiceManagerBindingResult binding = smClient.createBinding(tenantId, instanceId); + warmCache(tenantId, binding); + logger.info("Provisioned object store for tenant {}: instance={}", tenantId, instanceId); + } catch (ServiceManagerException e) { + // Binding creation failed — clean up orphaned instance + logger.error( + "Failed to create binding for tenant {}, cleaning up instance {}", tenantId, instanceId); + try { + smClient.deleteInstance(instanceId); + } catch (ServiceManagerException cleanupEx) { + logger.error("Failed to clean up orphaned instance {}", instanceId, cleanupEx); + } + throw e; + } + } + + /** + * Deprovisions the object store for a tenant. Deletes all objects in the bucket, then removes the + * SM binding and instance. Errors are logged but do not block the unsubscribe flow. + */ + public void onTenantUnsubscribe(String tenantId) { + logger.info("Deprovisioning object store for tenant {}", tenantId); + + var bindingResult = smClient.getBinding(tenantId); + if (bindingResult.isEmpty()) { + logger.warn("No binding found for tenant {} during unsubscribe, nothing to clean up", tenantId); + clientProvider.evict(tenantId); + return; + } + + var binding = bindingResult.get(); + + // Delete all objects in the tenant's bucket + try { + OSClient client = + OSClientFactory.create(new ServiceManagerBinding(binding.credentials()), executor); + client.deleteContentByPrefix("").get(); + logger.info("Deleted all objects for tenant {}", tenantId); + } catch (Exception e) { + logger.error("Failed to delete objects for tenant {}", tenantId, e); + } + + clientProvider.evict(tenantId); + + // Delete SM binding + try { + smClient.deleteBinding(binding.bindingId()); + } catch (ServiceManagerException e) { + logger.error("Failed to delete SM binding for tenant {}", tenantId, e); + } + + // Delete SM instance + try { + smClient.deleteInstance(binding.instanceId()); + } catch (ServiceManagerException e) { + logger.error("Failed to delete SM instance for tenant {}", tenantId, e); + } + + logger.info("Deprovisioned object store for tenant {}", tenantId); + } + + private void warmCache(String tenantId, ServiceManagerBindingResult binding) { + OSClient client = + OSClientFactory.create(new ServiceManagerBinding(binding.credentials()), executor); + clientProvider.put(tenantId, client); + } +} diff --git a/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/multitenancy/ObjectStoreSubscribeHandler.java b/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/multitenancy/ObjectStoreSubscribeHandler.java new file mode 100644 index 00000000..ccbc81bd --- /dev/null +++ b/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/multitenancy/ObjectStoreSubscribeHandler.java @@ -0,0 +1,26 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. + */ +package com.sap.cds.feature.attachments.oss.multitenancy; + +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.SubscribeEventContext; + +/** CAP event handler that provisions a per-tenant object store bucket on tenant subscription. */ +@ServiceName(DeploymentService.DEFAULT_NAME) +public class ObjectStoreSubscribeHandler implements EventHandler { + + private final ObjectStoreLifecycleHandler lifecycleHandler; + + public ObjectStoreSubscribeHandler(ObjectStoreLifecycleHandler lifecycleHandler) { + this.lifecycleHandler = lifecycleHandler; + } + + @After(event = DeploymentService.EVENT_SUBSCRIBE) + void onSubscribe(SubscribeEventContext context) { + lifecycleHandler.onTenantSubscribe(context.getTenant()); + } +} diff --git a/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/multitenancy/ObjectStoreUnsubscribeHandler.java b/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/multitenancy/ObjectStoreUnsubscribeHandler.java new file mode 100644 index 00000000..3780f35e --- /dev/null +++ b/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/multitenancy/ObjectStoreUnsubscribeHandler.java @@ -0,0 +1,28 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. + */ +package com.sap.cds.feature.attachments.oss.multitenancy; + +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; + +/** + * CAP event handler that deprovisions a per-tenant object store bucket on tenant unsubscription. + */ +@ServiceName(DeploymentService.DEFAULT_NAME) +public class ObjectStoreUnsubscribeHandler implements EventHandler { + + private final ObjectStoreLifecycleHandler lifecycleHandler; + + public ObjectStoreUnsubscribeHandler(ObjectStoreLifecycleHandler lifecycleHandler) { + this.lifecycleHandler = lifecycleHandler; + } + + @After(event = DeploymentService.EVENT_UNSUBSCRIBE) + void onUnsubscribe(UnsubscribeEventContext context) { + lifecycleHandler.onTenantUnsubscribe(context.getTenant()); + } +} diff --git a/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/multitenancy/SeparateOSClientProvider.java b/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/multitenancy/SeparateOSClientProvider.java new file mode 100644 index 00000000..fd908e54 --- /dev/null +++ b/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/multitenancy/SeparateOSClientProvider.java @@ -0,0 +1,79 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. + */ +package com.sap.cds.feature.attachments.oss.multitenancy; + +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.client.OSClientProvider; +import com.sap.cds.feature.attachments.oss.multitenancy.sm.ServiceManagerBinding; +import com.sap.cds.feature.attachments.oss.multitenancy.sm.ServiceManagerClient; +import com.sap.cds.feature.attachments.oss.multitenancy.sm.ServiceManagerClient.ServiceManagerBindingResult; +import java.time.Duration; +import java.time.Instant; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * An {@link OSClientProvider} for separate-bucket multitenancy mode. Each tenant gets its own + * dedicated object store bucket, and the corresponding {@link OSClient} is cached in a {@link + * ConcurrentHashMap} with a configurable TTL. + */ +public class SeparateOSClientProvider implements OSClientProvider { + + private static final Logger logger = LoggerFactory.getLogger(SeparateOSClientProvider.class); + + private final ConcurrentHashMap cache = new ConcurrentHashMap<>(); + private final ServiceManagerClient smClient; + private final ExecutorService executor; + private final Duration credentialTtl; + + public SeparateOSClientProvider( + ServiceManagerClient smClient, ExecutorService executor, Duration credentialTtl) { + this.smClient = smClient; + this.executor = executor; + this.credentialTtl = credentialTtl; + } + + @Override + public OSClient getClient(String tenantId) { + CachedOSClient entry = + cache.compute( + tenantId, + (id, existing) -> { + if (existing != null && !existing.isExpired(credentialTtl)) { + return existing; + } + logger.debug( + "Resolving OSClient for tenant {} ({})", + id, + existing == null ? "cache miss" : "expired"); + return createClient(id); + }); + return entry.client(); + } + + /** Removes the cached client for a tenant. */ + public void evict(String tenantId) { + cache.remove(tenantId); + logger.debug("Evicted cached OSClient for tenant {}", tenantId); + } + + /** Warms the cache with a pre-created client for a tenant. */ + public void put(String tenantId, OSClient client) { + cache.put(tenantId, new CachedOSClient(client, Instant.now())); + logger.debug("Warmed cache for tenant {}", tenantId); + } + + private CachedOSClient createClient(String tenantId) { + ServiceManagerBindingResult binding = + smClient + .getBinding(tenantId) + .orElseThrow(() -> new TenantNotProvisionedException(tenantId)); + OSClient client = + OSClientFactory.create(new ServiceManagerBinding(binding.credentials()), executor); + return new CachedOSClient(client, Instant.now()); + } +} diff --git a/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/multitenancy/TenantNotProvisionedException.java b/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/multitenancy/TenantNotProvisionedException.java new file mode 100644 index 00000000..13c83985 --- /dev/null +++ b/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/multitenancy/TenantNotProvisionedException.java @@ -0,0 +1,14 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. + */ +package com.sap.cds.feature.attachments.oss.multitenancy; + +import com.sap.cds.services.ServiceException; + +/** Thrown when a request is made for a tenant that has no provisioned object store. */ +public class TenantNotProvisionedException extends ServiceException { + + public TenantNotProvisionedException(String tenantId) { + super("No object store provisioned for tenant: " + tenantId); + } +} diff --git a/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/multitenancy/sm/ServiceManagerBinding.java b/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/multitenancy/sm/ServiceManagerBinding.java new file mode 100644 index 00000000..247ed752 --- /dev/null +++ b/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/multitenancy/sm/ServiceManagerBinding.java @@ -0,0 +1,70 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. + */ +package com.sap.cds.feature.attachments.oss.multitenancy.sm; + +import com.sap.cloud.environment.servicebinding.api.ServiceBinding; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +/** + * Adapts a Service Manager binding response (JSON credentials map) into the {@link ServiceBinding} + * interface expected by {@link + * com.sap.cds.feature.attachments.oss.client.OSClientFactory#create}. + */ +public class ServiceManagerBinding implements ServiceBinding { + + private final Map credentials; + + /** + * Creates a new adapter from the credentials map returned by the Service Manager binding API. + * + * @param credentials the credentials from the SM binding response + */ + public ServiceManagerBinding(Map credentials) { + this.credentials = Collections.unmodifiableMap(credentials); + } + + @Override + public Map getCredentials() { + return credentials; + } + + @Override + public Set getKeys() { + return credentials.keySet(); + } + + @Override + public boolean containsKey(String key) { + return credentials.containsKey(key); + } + + @Override + public Optional get(String key) { + return Optional.ofNullable(credentials.get(key)); + } + + @Override + public Optional getName() { + return Optional.empty(); + } + + @Override + public Optional getServiceName() { + return Optional.of("objectstore"); + } + + @Override + public Optional getServicePlan() { + return Optional.empty(); + } + + @Override + public List getTags() { + return Collections.emptyList(); + } +} diff --git a/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/multitenancy/sm/ServiceManagerClient.java b/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/multitenancy/sm/ServiceManagerClient.java new file mode 100644 index 00000000..c5dbc965 --- /dev/null +++ b/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/multitenancy/sm/ServiceManagerClient.java @@ -0,0 +1,314 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. + */ +package com.sap.cds.feature.attachments.oss.multitenancy.sm; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.io.IOException; +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Map; +import java.util.Optional; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * REST client for the SAP BTP Service Manager API. Manages object store service instances and + * bindings for per-tenant bucket provisioning (separate bucket multitenancy mode). + */ +public class ServiceManagerClient { + + private static final Logger logger = LoggerFactory.getLogger(ServiceManagerClient.class); + private static final ObjectMapper MAPPER = new ObjectMapper(); + + static final Duration DEFAULT_POLL_TIMEOUT = Duration.ofMinutes(5); + static final Duration INITIAL_POLL_INTERVAL = Duration.ofSeconds(5); + + private final String smUrl; + private final ServiceManagerTokenProvider tokenProvider; + private final HttpClient httpClient; + private final Duration pollTimeout; + + public ServiceManagerClient( + ServiceManagerCredentials credentials, + ServiceManagerTokenProvider tokenProvider, + HttpClient httpClient) { + this(credentials, tokenProvider, httpClient, DEFAULT_POLL_TIMEOUT); + } + + ServiceManagerClient( + ServiceManagerCredentials credentials, + ServiceManagerTokenProvider tokenProvider, + HttpClient httpClient, + Duration pollTimeout) { + this.smUrl = credentials.smUrl(); + this.tokenProvider = tokenProvider; + this.httpClient = httpClient; + this.pollTimeout = pollTimeout; + } + + // --- Read Operations --- + + /** Returns the Service Manager offering ID for the "objectstore" service. */ + public String getOfferingId() { + String url = smUrl + "/v1/service_offerings?fieldQuery=" + encode("name eq 'objectstore'"); + JsonNode json = sendGet(url); + JsonNode items = json.get("items"); + if (items == null || items.isEmpty()) { + throw new ServiceManagerException("No 'objectstore' service offering found"); + } + return items.get(0).get("id").asText(); + } + + /** Returns the plan ID for the given offering. Tries "standard", then "s3-standard". */ + public String getPlanId(String offeringId) { + for (String planName : new String[] {"standard", "s3-standard"}) { + String query = "service_offering_id eq '%s' and name eq '%s'".formatted(offeringId, planName); + String url = smUrl + "/v1/service_plans?fieldQuery=" + encode(query); + JsonNode json = sendGet(url); + JsonNode items = json.get("items"); + if (items != null && !items.isEmpty()) { + return items.get(0).get("id").asText(); + } + } + throw new ServiceManagerException( + "No 'standard' or 's3-standard' plan found for offering " + offeringId); + } + + /** + * Looks up an existing service binding for the given tenant. + * + * @return the binding credentials, or empty if no binding exists + */ + public Optional getBinding(String tenantId) { + String labelQuery = "tenant_id eq '%s'".formatted(tenantId); + String url = smUrl + "/v1/service_bindings?labelQuery=" + encode(labelQuery); + JsonNode json = sendGet(url); + JsonNode items = json.get("items"); + if (items == null || items.isEmpty()) { + return Optional.empty(); + } + JsonNode binding = items.get(0); + String bindingId = binding.get("id").asText(); + String instanceId = binding.get("service_instance_id").asText(); + @SuppressWarnings("unchecked") + Map credentials = + MAPPER.convertValue(binding.get("credentials"), Map.class); + return Optional.of(new ServiceManagerBindingResult(bindingId, instanceId, credentials)); + } + + /** + * Looks up an existing service instance for the given tenant. + * + * @return the instance ID, or empty if none exists + */ + public Optional getInstanceByTenant(String tenantId) { + String labelQuery = "tenant_id eq '%s'".formatted(tenantId); + String url = smUrl + "/v1/service_instances?labelQuery=" + encode(labelQuery); + JsonNode json = sendGet(url); + JsonNode items = json.get("items"); + if (items == null || items.isEmpty()) { + return Optional.empty(); + } + return Optional.of(items.get(0).get("id").asText()); + } + + // --- Write Operations --- + + /** + * Creates a new object store service instance for a tenant. + * + * @return the instance ID + */ + public String createInstance(String tenantId, String planId) { + ObjectNode body = MAPPER.createObjectNode(); + body.put("name", "object-store-" + tenantId); + body.put("service_plan_id", planId); + ObjectNode labels = body.putObject("labels"); + ArrayNode tenantLabel = labels.putArray("tenant_id"); + tenantLabel.add(tenantId); + ArrayNode serviceLabel = labels.putArray("service"); + serviceLabel.add("OBJECT_STORE"); + + HttpResponse response = sendPost(smUrl + "/v1/service_instances", body.toString()); + + if (response.statusCode() == 201) { + return parseJson(response.body()).get("id").asText(); + } else if (response.statusCode() == 202) { + // Async creation — poll using Location header + String location = response.headers().firstValue("Location").orElse(null); + if (location == null) { + throw new ServiceManagerException("Async instance creation returned no Location header"); + } + String fullUrl = location.startsWith("http") ? location : smUrl + location; + JsonNode result = pollUntilDone(fullUrl); + return result.get("id").asText(); + } else { + throw new ServiceManagerException( + "Failed to create service instance for tenant %s: HTTP %d - %s" + .formatted(tenantId, response.statusCode(), response.body())); + } + } + + /** + * Creates a service binding for a tenant's instance. + * + * @return the binding credentials + */ + @SuppressWarnings("unchecked") + public ServiceManagerBindingResult createBinding(String tenantId, String instanceId) { + ObjectNode body = MAPPER.createObjectNode(); + body.put("name", "object-store-binding-" + tenantId); + body.put("service_instance_id", instanceId); + ObjectNode labels = body.putObject("labels"); + ArrayNode tenantLabel = labels.putArray("tenant_id"); + tenantLabel.add(tenantId); + ArrayNode serviceLabel = labels.putArray("service"); + serviceLabel.add("OBJECT_STORE"); + + HttpResponse response = sendPost(smUrl + "/v1/service_bindings", body.toString()); + + if (response.statusCode() != 201) { + throw new ServiceManagerException( + "Failed to create binding for tenant %s: HTTP %d - %s" + .formatted(tenantId, response.statusCode(), response.body())); + } + JsonNode json = parseJson(response.body()); + String bindingId = json.get("id").asText(); + Map credentials = MAPPER.convertValue(json.get("credentials"), Map.class); + return new ServiceManagerBindingResult(bindingId, instanceId, credentials); + } + + /** Deletes a service binding. */ + public void deleteBinding(String bindingId) { + HttpResponse response = sendDelete(smUrl + "/v1/service_bindings/" + bindingId); + if (response.statusCode() != 200 && response.statusCode() != 204) { + throw new ServiceManagerException( + "Failed to delete binding %s: HTTP %d - %s" + .formatted(bindingId, response.statusCode(), response.body())); + } + logger.info("Deleted SM binding {}", bindingId); + } + + /** Deletes a service instance. Handles async deletion with polling. */ + public void deleteInstance(String instanceId) { + HttpResponse response = sendDelete(smUrl + "/v1/service_instances/" + instanceId); + if (response.statusCode() == 200 || response.statusCode() == 204) { + logger.info("Deleted SM instance {}", instanceId); + } else if (response.statusCode() == 202) { + String location = response.headers().firstValue("Location").orElse(null); + if (location != null) { + String fullUrl = location.startsWith("http") ? location : smUrl + location; + pollUntilDone(fullUrl); + } + logger.info("Deleted SM instance {} (async)", instanceId); + } else { + throw new ServiceManagerException( + "Failed to delete instance %s: HTTP %d - %s" + .formatted(instanceId, response.statusCode(), response.body())); + } + } + + // --- Polling --- + + JsonNode pollUntilDone(String url) { + long startTime = System.currentTimeMillis(); + int iteration = 1; + while (true) { + long elapsed = System.currentTimeMillis() - startTime; + if (elapsed > pollTimeout.toMillis()) { + throw new ServiceManagerException("Polling timed out after " + pollTimeout); + } + + Duration sleepDuration = INITIAL_POLL_INTERVAL.multipliedBy(iteration); + try { + Thread.sleep(sleepDuration.toMillis()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new ServiceManagerException("Interrupted while polling", e); + } + + JsonNode json = sendGet(url); + String state = + json.has("state") ? json.get("state").asText() : json.path("last_operation").path("state").asText(""); + if ("succeeded".equals(state)) { + return json; + } else if ("failed".equals(state)) { + String description = json.path("last_operation").path("description").asText("unknown"); + throw new ServiceManagerException("Operation failed: " + description); + } + logger.debug("Polling SM operation, state={}, iteration={}", state, iteration); + iteration++; + } + } + + // --- HTTP Helpers --- + + private JsonNode sendGet(String url) { + HttpRequest request = + HttpRequest.newBuilder() + .uri(URI.create(url)) + .header("Authorization", "Bearer " + tokenProvider.getAccessToken()) + .GET() + .build(); + return parseJson(sendRequest(request).body()); + } + + private HttpResponse sendPost(String url, String body) { + HttpRequest request = + HttpRequest.newBuilder() + .uri(URI.create(url)) + .header("Authorization", "Bearer " + tokenProvider.getAccessToken()) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(body)) + .build(); + return sendRequest(request); + } + + private HttpResponse sendDelete(String url) { + HttpRequest request = + HttpRequest.newBuilder() + .uri(URI.create(url)) + .header("Authorization", "Bearer " + tokenProvider.getAccessToken()) + .DELETE() + .build(); + return sendRequest(request); + } + + private HttpResponse sendRequest(HttpRequest request) { + try { + return httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + } catch (IOException e) { + throw new ServiceManagerException( + "HTTP request failed: %s %s".formatted(request.method(), request.uri()), e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new ServiceManagerException( + "Interrupted during HTTP request: %s %s".formatted(request.method(), request.uri()), e); + } + } + + private static JsonNode parseJson(String json) { + try { + return MAPPER.readTree(json); + } catch (IOException e) { + throw new ServiceManagerException("Failed to parse JSON response", e); + } + } + + private static String encode(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8); + } + + /** Result of a binding lookup or creation, containing IDs and credentials. */ + public record ServiceManagerBindingResult( + String bindingId, String instanceId, Map credentials) {} +} diff --git a/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/multitenancy/sm/ServiceManagerCredentials.java b/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/multitenancy/sm/ServiceManagerCredentials.java new file mode 100644 index 00000000..08f12d46 --- /dev/null +++ b/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/multitenancy/sm/ServiceManagerCredentials.java @@ -0,0 +1,45 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. + */ +package com.sap.cds.feature.attachments.oss.multitenancy.sm; + +import com.sap.cloud.environment.servicebinding.api.ServiceBinding; +import java.util.Map; + +/** + * Holds the credentials required to authenticate with SAP BTP Service Manager. Typically extracted + * from a {@code service-manager} service binding. + */ +public record ServiceManagerCredentials( + String smUrl, + String authUrl, + String clientId, + String clientSecret, + String certificate, + String key, + String certUrl) { + + /** + * Creates {@link ServiceManagerCredentials} from a {@code service-manager} {@link + * ServiceBinding}. + * + * @param binding the service-manager service binding + * @return the extracted credentials + */ + public static ServiceManagerCredentials fromServiceBinding(ServiceBinding binding) { + Map creds = binding.getCredentials(); + return new ServiceManagerCredentials( + (String) creds.get("sm_url"), + (String) creds.get("url"), + (String) creds.get("clientid"), + (String) creds.get("clientsecret"), + (String) creds.get("certificate"), + (String) creds.get("key"), + (String) creds.get("certurl")); + } + + /** Returns {@code true} if mTLS credentials are available. */ + public boolean hasMtlsCredentials() { + return certificate != null && !certificate.isEmpty() && key != null && !key.isEmpty(); + } +} diff --git a/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/multitenancy/sm/ServiceManagerException.java b/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/multitenancy/sm/ServiceManagerException.java new file mode 100644 index 00000000..6d309a4b --- /dev/null +++ b/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/multitenancy/sm/ServiceManagerException.java @@ -0,0 +1,16 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. + */ +package com.sap.cds.feature.attachments.oss.multitenancy.sm; + +/** Exception thrown when a Service Manager operation fails. */ +public class ServiceManagerException extends RuntimeException { + + public ServiceManagerException(String message) { + super(message); + } + + public ServiceManagerException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/multitenancy/sm/ServiceManagerTokenProvider.java b/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/multitenancy/sm/ServiceManagerTokenProvider.java new file mode 100644 index 00000000..1d23556e --- /dev/null +++ b/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/multitenancy/sm/ServiceManagerTokenProvider.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.multitenancy.sm; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Provides OAuth access tokens for authenticating with SAP BTP Service Manager. Caches the token + * in-memory and refreshes it before expiry. + */ +public class ServiceManagerTokenProvider { + + private static final Logger logger = LoggerFactory.getLogger(ServiceManagerTokenProvider.class); + private static final Duration REFRESH_MARGIN = Duration.ofMinutes(5); + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private final ServiceManagerCredentials credentials; + private final HttpClient httpClient; + + private String cachedToken; + private Instant tokenExpiry = Instant.MIN; + + public ServiceManagerTokenProvider( + ServiceManagerCredentials credentials, HttpClient httpClient) { + this.credentials = credentials; + this.httpClient = httpClient; + } + + /** + * Returns a valid access token, fetching or refreshing as needed. + * + * @return the OAuth access token + * @throws ServiceManagerException if the token cannot be obtained + */ + public synchronized String getAccessToken() { + if (cachedToken != null && Instant.now().isBefore(tokenExpiry.minus(REFRESH_MARGIN))) { + return cachedToken; + } + return fetchToken(); + } + + private String fetchToken() { + String tokenUrl = credentials.authUrl() + "/oauth/token"; + String body = + "grant_type=client_credentials" + + "&client_id=" + + URLEncoder.encode(credentials.clientId(), StandardCharsets.UTF_8) + + "&client_secret=" + + URLEncoder.encode(credentials.clientSecret(), StandardCharsets.UTF_8); + + HttpRequest request = + HttpRequest.newBuilder() + .uri(URI.create(tokenUrl)) + .header("Content-Type", "application/x-www-form-urlencoded") + .POST(HttpRequest.BodyPublishers.ofString(body)) + .build(); + + try { + HttpResponse response = + httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() != 200) { + throw new ServiceManagerException( + "Failed to obtain Service Manager token: HTTP %d - %s" + .formatted(response.statusCode(), response.body())); + } + + JsonNode json = MAPPER.readTree(response.body()); + cachedToken = json.get("access_token").asText(); + int expiresIn = json.get("expires_in").asInt(); + tokenExpiry = Instant.now().plusSeconds(expiresIn); + logger.debug("Obtained Service Manager token, expires in {} seconds", expiresIn); + return cachedToken; + } catch (IOException e) { + throw new ServiceManagerException("Failed to obtain Service Manager token", e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new ServiceManagerException("Interrupted while obtaining Service Manager token", e); + } + } +} 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 3987e1a7..c5becd76 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 @@ -11,6 +11,8 @@ import static org.mockito.Mockito.when; import com.sap.cds.feature.attachments.oss.handler.OSSAttachmentsServiceHandler; +import com.sap.cds.feature.attachments.oss.multitenancy.ObjectStoreSubscribeHandler; +import com.sap.cds.feature.attachments.oss.multitenancy.ObjectStoreUnsubscribeHandler; import com.sap.cds.services.environment.CdsEnvironment; import com.sap.cds.services.runtime.CdsRuntime; import com.sap.cds.services.runtime.CdsRuntimeConfigurer; @@ -20,6 +22,7 @@ import java.util.Optional; import java.util.stream.Stream; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; class RegistrationTest { @@ -42,6 +45,18 @@ private static ServiceBinding createAwsBinding() { return binding; } + private static ServiceBinding createServiceManagerBinding() { + ServiceBinding binding = mock(ServiceBinding.class); + Map credentials = new HashMap<>(); + credentials.put("sm_url", "https://sm.example.com"); + credentials.put("url", "https://auth.example.com"); + credentials.put("clientid", "test-client"); + credentials.put("clientsecret", "test-secret"); + when(binding.getServiceName()).thenReturn(Optional.of("service-manager")); + when(binding.getCredentials()).thenReturn(credentials); + return binding; + } + @BeforeEach void setup() { registration = new Registration(); @@ -55,72 +70,114 @@ void setup() { awsBinding = createAwsBinding(); } - @Test - void testEventHandlersRegistersOSSHandler() { - when(environment.getServiceBindings()).thenReturn(Stream.of(awsBinding)); + @Nested + class SharedAndSingleTenantMode { - registration.eventHandlers(configurer); + @Test + void testEventHandlersRegistersOSSHandler() { + when(environment.getServiceBindings()).thenReturn(Stream.of(awsBinding)); - verify(configurer).eventHandler(any(OSSAttachmentsServiceHandler.class)); - } + registration.eventHandlers(configurer); - @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"); + verify(configurer).eventHandler(any(OSSAttachmentsServiceHandler.class)); + } - registration.eventHandlers(configurer); + @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"); - verify(configurer, times(2)).eventHandler(any()); - } + registration.eventHandlers(configurer); - @Test - void testEventHandlersNoBindingDoesNotRegister() { - when(environment.getServiceBindings()).thenReturn(Stream.empty()); + verify(configurer, times(2)).eventHandler(any()); + } - registration.eventHandlers(configurer); + @Test + void testEventHandlersNoBindingDoesNotRegister() { + when(environment.getServiceBindings()).thenReturn(Stream.empty()); - verify(configurer, never()).eventHandler(any()); - } + registration.eventHandlers(configurer); - @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"); + verify(configurer, never()).eventHandler(any()); + } - registration.eventHandlers(configurer); + @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"); - verify(configurer, times(1)).eventHandler(any(OSSAttachmentsServiceHandler.class)); - verify(configurer, times(1)).eventHandler(any()); - } + registration.eventHandlers(configurer); - @Test - void testMtEnabledNullKindRegistersOnlyOSSHandler() { - when(environment.getServiceBindings()).thenReturn(Stream.of(awsBinding)); - when(environment.getProperty("cds.multitenancy.enabled", Boolean.class, Boolean.FALSE)) - .thenReturn(Boolean.TRUE); + verify(configurer, times(1)).eventHandler(any(OSSAttachmentsServiceHandler.class)); + verify(configurer, times(1)).eventHandler(any()); + } - registration.eventHandlers(configurer); + @Test + void testMtEnabledNullKindRegistersOnlyOSSHandler() { + when(environment.getServiceBindings()).thenReturn(Stream.of(awsBinding)); + when(environment.getProperty("cds.multitenancy.enabled", Boolean.class, Boolean.FALSE)) + .thenReturn(Boolean.TRUE); - verify(configurer, times(1)).eventHandler(any(OSSAttachmentsServiceHandler.class)); - verify(configurer, times(1)).eventHandler(any()); - } + registration.eventHandlers(configurer); - @Test - void testMtDisabledSharedKindRegistersOnlyOSSHandler() { - when(environment.getServiceBindings()).thenReturn(Stream.of(awsBinding)); - when(environment.getProperty("cds.attachments.objectStore.kind", String.class, null)) - .thenReturn("shared"); + verify(configurer, times(1)).eventHandler(any(OSSAttachmentsServiceHandler.class)); + verify(configurer, times(1)).eventHandler(any()); + } - registration.eventHandlers(configurer); + @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()); + } + } - verify(configurer, times(1)).eventHandler(any(OSSAttachmentsServiceHandler.class)); - verify(configurer, times(1)).eventHandler(any()); + @Nested + class SeparateMode { + + @Test + void testSeparateModeRegistersHandlerAndLifecycleHandlers() { + ServiceBinding smBinding = createServiceManagerBinding(); + when(environment.getServiceBindings()).thenReturn(Stream.of(smBinding)); + when(environment.getProperty("cds.multitenancy.enabled", Boolean.class, Boolean.FALSE)) + .thenReturn(Boolean.TRUE); + when(environment.getProperty("cds.attachments.objectStore.kind", String.class, null)) + .thenReturn("separate"); + when(environment.getProperty( + "cds.attachments.objectStore.separate.credentialTtlHours", Integer.class, 11)) + .thenReturn(11); + + registration.eventHandlers(configurer); + + // Should register: OSSAttachmentsServiceHandler + SubscribeHandler + UnsubscribeHandler = 3 + verify(configurer, times(1)).eventHandler(any(OSSAttachmentsServiceHandler.class)); + verify(configurer, times(1)).eventHandler(any(ObjectStoreSubscribeHandler.class)); + verify(configurer, times(1)).eventHandler(any(ObjectStoreUnsubscribeHandler.class)); + verify(configurer, times(3)).eventHandler(any()); + } + + @Test + void testSeparateModeWithoutSmBindingDoesNotRegister() { + when(environment.getServiceBindings()).thenReturn(Stream.empty()); + when(environment.getProperty("cds.multitenancy.enabled", Boolean.class, Boolean.FALSE)) + .thenReturn(Boolean.TRUE); + when(environment.getProperty("cds.attachments.objectStore.kind", String.class, null)) + .thenReturn("separate"); + + registration.eventHandlers(configurer); + + verify(configurer, never()).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 91bc9892..9122f649 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 @@ -17,6 +17,7 @@ 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.oss.client.SharedOSClientProvider; 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; @@ -145,7 +146,7 @@ class SingleTenantOperations { @BeforeEach void setup() { mockOsClient = mock(OSClient.class); - handler = new OSSAttachmentsServiceHandler(mockOsClient, false, null); + handler = new OSSAttachmentsServiceHandler(new SharedOSClientProvider(mockOsClient), false, null); } @Test @@ -224,7 +225,7 @@ class ExceptionHandling { @BeforeEach void setup() { mockOsClient = mock(OSClient.class); - handler = new OSSAttachmentsServiceHandler(mockOsClient, false, null); + handler = new OSSAttachmentsServiceHandler(new SharedOSClientProvider(mockOsClient), false, null); } @Test @@ -333,7 +334,7 @@ class MultitenancyTests { @BeforeEach void setup() { mockOsClient = mock(OSClient.class); - handler = new OSSAttachmentsServiceHandler(mockOsClient, true, "shared"); + handler = new OSSAttachmentsServiceHandler(new SharedOSClientProvider(mockOsClient), true, "shared"); } @Test 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 e0fbc6a0..5e1e61f1 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 @@ -16,6 +16,8 @@ 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.oss.client.OSClientProvider; +import com.sap.cds.feature.attachments.oss.client.SharedOSClientProvider; 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; @@ -41,7 +43,8 @@ public static void testCreateReadDeleteAttachmentFlow( String testFileContent = "test"; OSClient osClient = OSClientFactory.create(binding, executor); - OSSAttachmentsServiceHandler handler = new OSSAttachmentsServiceHandler(osClient, false, null); + OSSAttachmentsServiceHandler handler = + new OSSAttachmentsServiceHandler(new SharedOSClientProvider(osClient), false, null); // Create an AttachmentCreateEventContext with mocked data - to upload a test attachment MediaData createMediaData = mock(MediaData.class); @@ -104,11 +107,11 @@ public static void testCreateReadDeleteAttachmentFlow( verify(deleteContext).setCompleted(); } - // Helper to access private static osClient - public static OSClient getOsClient(OSSAttachmentsServiceHandler handler) + // Helper to access private osClientProvider + public static OSClientProvider getOsClientProvider(OSSAttachmentsServiceHandler handler) throws NoSuchFieldException, IllegalAccessException { - var field = OSSAttachmentsServiceHandler.class.getDeclaredField("osClient"); + var field = OSSAttachmentsServiceHandler.class.getDeclaredField("osClientProvider"); field.setAccessible(true); - return (OSClient) field.get(handler); + return (OSClientProvider) field.get(handler); } } diff --git a/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/multitenancy/ObjectStoreLifecycleHandlerTest.java b/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/multitenancy/ObjectStoreLifecycleHandlerTest.java new file mode 100644 index 00000000..0c6e2f7f --- /dev/null +++ b/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/multitenancy/ObjectStoreLifecycleHandlerTest.java @@ -0,0 +1,134 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. + */ +package com.sap.cds.feature.attachments.oss.multitenancy; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.sap.cds.feature.attachments.oss.multitenancy.sm.ServiceManagerClient; +import com.sap.cds.feature.attachments.oss.multitenancy.sm.ServiceManagerClient.ServiceManagerBindingResult; +import com.sap.cds.feature.attachments.oss.multitenancy.sm.ServiceManagerException; +import java.util.Map; +import java.util.Optional; +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 ObjectStoreLifecycleHandlerTest { + + private ServiceManagerClient smClient; + private SeparateOSClientProvider clientProvider; + private ObjectStoreLifecycleHandler handler; + private static final ExecutorService executor = Executors.newCachedThreadPool(); + + private static final Map AWS_CREDS = + Map.of( + "host", "s3.aws.com", + "bucket", "tenant-bucket", + "region", "us-east-1", + "access_key_id", "ak", + "secret_access_key", "sk"); + + @BeforeEach + void setup() { + smClient = mock(ServiceManagerClient.class); + clientProvider = mock(SeparateOSClientProvider.class); + handler = new ObjectStoreLifecycleHandler(smClient, clientProvider, executor); + } + + @Nested + class Subscribe { + + @Test + void testSubscribeCreatesInstanceAndBinding() { + when(smClient.getBinding("t1")).thenReturn(Optional.empty()); + when(smClient.getOfferingId()).thenReturn("off-1"); + when(smClient.getPlanId("off-1")).thenReturn("plan-1"); + when(smClient.createInstance("t1", "plan-1")).thenReturn("inst-1"); + when(smClient.createBinding("t1", "inst-1")) + .thenReturn(new ServiceManagerBindingResult("bind-1", "inst-1", AWS_CREDS)); + + handler.onTenantSubscribe("t1"); + + verify(smClient).createInstance("t1", "plan-1"); + verify(smClient).createBinding("t1", "inst-1"); + verify(clientProvider).put(eq("t1"), any()); + } + + @Test + void testSubscribeIsIdempotentWhenBindingExists() { + when(smClient.getBinding("t1")) + .thenReturn(Optional.of(new ServiceManagerBindingResult("bind-1", "inst-1", AWS_CREDS))); + + handler.onTenantSubscribe("t1"); + + verify(smClient, never()).createInstance(anyString(), anyString()); + verify(smClient, never()).createBinding(anyString(), anyString()); + } + + @Test + void testSubscribeCleansUpInstanceOnBindingFailure() { + when(smClient.getBinding("t1")).thenReturn(Optional.empty()); + when(smClient.getOfferingId()).thenReturn("off-1"); + when(smClient.getPlanId("off-1")).thenReturn("plan-1"); + when(smClient.createInstance("t1", "plan-1")).thenReturn("inst-1"); + doThrow(new ServiceManagerException("binding failed")) + .when(smClient) + .createBinding("t1", "inst-1"); + + assertThrows(ServiceManagerException.class, () -> handler.onTenantSubscribe("t1")); + + verify(smClient).deleteInstance("inst-1"); + } + } + + @Nested + class Unsubscribe { + + @Test + void testUnsubscribeDeletesBindingAndInstance() { + when(smClient.getBinding("t1")) + .thenReturn(Optional.of(new ServiceManagerBindingResult("bind-1", "inst-1", AWS_CREDS))); + + handler.onTenantUnsubscribe("t1"); + + verify(clientProvider).evict("t1"); + verify(smClient).deleteBinding("bind-1"); + verify(smClient).deleteInstance("inst-1"); + } + + @Test + void testUnsubscribeHandlesNoBindingGracefully() { + when(smClient.getBinding("t1")).thenReturn(Optional.empty()); + + handler.onTenantUnsubscribe("t1"); + + verify(clientProvider).evict("t1"); + verify(smClient, never()).deleteBinding(anyString()); + verify(smClient, never()).deleteInstance(anyString()); + } + + @Test + void testUnsubscribeContinuesOnDeleteBindingFailure() { + when(smClient.getBinding("t1")) + .thenReturn(Optional.of(new ServiceManagerBindingResult("bind-1", "inst-1", AWS_CREDS))); + doThrow(new ServiceManagerException("delete failed")) + .when(smClient) + .deleteBinding("bind-1"); + + handler.onTenantUnsubscribe("t1"); // should not throw + + verify(smClient).deleteInstance("inst-1"); // still attempts instance delete + } + } +} diff --git a/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/multitenancy/ObjectStoreSubscribeHandlerTest.java b/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/multitenancy/ObjectStoreSubscribeHandlerTest.java new file mode 100644 index 00000000..bb0d52ce --- /dev/null +++ b/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/multitenancy/ObjectStoreSubscribeHandlerTest.java @@ -0,0 +1,27 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. + */ +package com.sap.cds.feature.attachments.oss.multitenancy; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.sap.cds.services.mt.SubscribeEventContext; +import org.junit.jupiter.api.Test; + +class ObjectStoreSubscribeHandlerTest { + + @Test + void testOnSubscribeDelegatesToLifecycleHandler() { + ObjectStoreLifecycleHandler lifecycle = mock(ObjectStoreLifecycleHandler.class); + ObjectStoreSubscribeHandler handler = new ObjectStoreSubscribeHandler(lifecycle); + + SubscribeEventContext context = mock(SubscribeEventContext.class); + when(context.getTenant()).thenReturn("tenant-1"); + + handler.onSubscribe(context); + + verify(lifecycle).onTenantSubscribe("tenant-1"); + } +} diff --git a/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/multitenancy/ObjectStoreUnsubscribeHandlerTest.java b/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/multitenancy/ObjectStoreUnsubscribeHandlerTest.java new file mode 100644 index 00000000..396248ea --- /dev/null +++ b/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/multitenancy/ObjectStoreUnsubscribeHandlerTest.java @@ -0,0 +1,27 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. + */ +package com.sap.cds.feature.attachments.oss.multitenancy; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.sap.cds.services.mt.UnsubscribeEventContext; +import org.junit.jupiter.api.Test; + +class ObjectStoreUnsubscribeHandlerTest { + + @Test + void testOnUnsubscribeDelegatesToLifecycleHandler() { + ObjectStoreLifecycleHandler lifecycle = mock(ObjectStoreLifecycleHandler.class); + ObjectStoreUnsubscribeHandler handler = new ObjectStoreUnsubscribeHandler(lifecycle); + + UnsubscribeEventContext context = mock(UnsubscribeEventContext.class); + when(context.getTenant()).thenReturn("tenant-1"); + + handler.onUnsubscribe(context); + + verify(lifecycle).onTenantUnsubscribe("tenant-1"); + } +} diff --git a/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/multitenancy/SeparateOSClientProviderTest.java b/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/multitenancy/SeparateOSClientProviderTest.java new file mode 100644 index 00000000..24bf6c70 --- /dev/null +++ b/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/multitenancy/SeparateOSClientProviderTest.java @@ -0,0 +1,117 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. + */ +package com.sap.cds.feature.attachments.oss.multitenancy; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +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.client.OSClient; +import com.sap.cds.feature.attachments.oss.multitenancy.sm.ServiceManagerClient; +import com.sap.cds.feature.attachments.oss.multitenancy.sm.ServiceManagerClient.ServiceManagerBindingResult; +import java.time.Duration; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class SeparateOSClientProviderTest { + + private ServiceManagerClient smClient; + private SeparateOSClientProvider provider; + private static final ExecutorService executor = Executors.newCachedThreadPool(); + + @BeforeEach + void setup() { + smClient = mock(ServiceManagerClient.class); + provider = new SeparateOSClientProvider(smClient, executor, Duration.ofHours(11)); + } + + private void stubBinding(String tenantId) { + Map creds = + Map.of("host", "s3.aws.com", "bucket", "b-" + tenantId, "region", "us-east-1", + "access_key_id", "ak", "secret_access_key", "sk"); + when(smClient.getBinding(tenantId)) + .thenReturn(Optional.of(new ServiceManagerBindingResult("bind-1", "inst-1", creds))); + } + + @Test + void testGetClientCreatesClientFromSmBinding() { + stubBinding("tenant-1"); + + OSClient client = provider.getClient("tenant-1"); + + assertThat(client).isNotNull(); + verify(smClient).getBinding("tenant-1"); + } + + @Test + void testGetClientCachesClient() { + stubBinding("tenant-1"); + + OSClient first = provider.getClient("tenant-1"); + OSClient second = provider.getClient("tenant-1"); + + assertThat(first).isSameAs(second); + verify(smClient, times(1)).getBinding("tenant-1"); + } + + @Test + void testGetClientDifferentTenants() { + stubBinding("tenant-1"); + stubBinding("tenant-2"); + + OSClient c1 = provider.getClient("tenant-1"); + OSClient c2 = provider.getClient("tenant-2"); + + assertThat(c1).isNotSameAs(c2); + } + + @Test + void testGetClientThrowsForUnprovisionedTenant() { + when(smClient.getBinding("unknown")).thenReturn(Optional.empty()); + + assertThrows(TenantNotProvisionedException.class, () -> provider.getClient("unknown")); + } + + @Test + void testEvictRemovesCachedClient() { + stubBinding("tenant-1"); + provider.getClient("tenant-1"); // warm cache + + provider.evict("tenant-1"); + provider.getClient("tenant-1"); // should fetch again + + verify(smClient, times(2)).getBinding("tenant-1"); + } + + @Test + void testPutWarmsCache() { + OSClient mockClient = mock(OSClient.class); + provider.put("tenant-1", mockClient); + + OSClient result = provider.getClient("tenant-1"); + + assertThat(result).isSameAs(mockClient); + verify(smClient, times(0)).getBinding("tenant-1"); + } + + @Test + void testExpiredEntryIsRefreshed() { + // Use a provider with 0 TTL so entries expire immediately + SeparateOSClientProvider shortTtl = + new SeparateOSClientProvider(smClient, executor, Duration.ZERO); + stubBinding("tenant-1"); + + shortTtl.getClient("tenant-1"); + shortTtl.getClient("tenant-1"); // should re-fetch due to expired TTL + + verify(smClient, times(2)).getBinding("tenant-1"); + } +} diff --git a/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/multitenancy/sm/ServiceManagerBindingTest.java b/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/multitenancy/sm/ServiceManagerBindingTest.java new file mode 100644 index 00000000..ec3e9369 --- /dev/null +++ b/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/multitenancy/sm/ServiceManagerBindingTest.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.multitenancy.sm; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Map; +import org.junit.jupiter.api.Test; + +class ServiceManagerBindingTest { + + @Test + void testGetCredentialsReturnsProvidedMap() { + Map creds = + Map.of("host", "s3.amazonaws.com", "bucket", "my-bucket", "region", "eu-west-1"); + var binding = new ServiceManagerBinding(creds); + + assertThat(binding.getCredentials()).isEqualTo(creds); + } + + @Test + void testGetServiceNameReturnsObjectstore() { + var binding = new ServiceManagerBinding(Map.of()); + + assertThat(binding.getServiceName()).hasValue("objectstore"); + } + + @Test + void testGetKeysReturnsCredentialKeys() { + var binding = new ServiceManagerBinding(Map.of("host", "example.com", "bucket", "b1")); + + assertThat(binding.getKeys()).containsExactlyInAnyOrder("host", "bucket"); + } + + @Test + void testContainsKeyReturnsTrueForPresent() { + var binding = new ServiceManagerBinding(Map.of("host", "example.com")); + + assertThat(binding.containsKey("host")).isTrue(); + assertThat(binding.containsKey("missing")).isFalse(); + } + + @Test + void testGetReturnsValueForPresentKey() { + var binding = new ServiceManagerBinding(Map.of("host", "example.com")); + + assertThat(binding.get("host")).hasValue("example.com"); + assertThat(binding.get("missing")).isEmpty(); + } + + @Test + void testGetNameReturnsEmpty() { + var binding = new ServiceManagerBinding(Map.of()); + + assertThat(binding.getName()).isEmpty(); + } + + @Test + void testGetServicePlanReturnsEmpty() { + var binding = new ServiceManagerBinding(Map.of()); + + assertThat(binding.getServicePlan()).isEmpty(); + } + + @Test + void testGetTagsReturnsEmptyList() { + var binding = new ServiceManagerBinding(Map.of()); + + assertThat(binding.getTags()).isEmpty(); + } + + @Test + void testCredentialsAreUnmodifiable() { + var binding = new ServiceManagerBinding(Map.of("key", "value")); + + org.junit.jupiter.api.Assertions.assertThrows( + UnsupportedOperationException.class, () -> binding.getCredentials().put("new", "val")); + } +} diff --git a/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/multitenancy/sm/ServiceManagerClientTest.java b/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/multitenancy/sm/ServiceManagerClientTest.java new file mode 100644 index 00000000..27fc128e --- /dev/null +++ b/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/multitenancy/sm/ServiceManagerClientTest.java @@ -0,0 +1,299 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. + */ +package com.sap.cds.feature.attachments.oss.multitenancy.sm; + +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.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.net.http.HttpClient; +import java.net.http.HttpHeaders; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class ServiceManagerClientTest { + + private HttpClient httpClient; + private ServiceManagerTokenProvider tokenProvider; + private ServiceManagerClient client; + + private static final ServiceManagerCredentials CREDS = + new ServiceManagerCredentials( + "https://sm.example.com", "https://auth.example.com", "id", "secret", null, null, null); + + @SuppressWarnings("unchecked") + private static HttpResponse mockResponse(int status, String body) { + return mockResponse(status, body, Map.of()); + } + + @SuppressWarnings("unchecked") + private static HttpResponse mockResponse( + int status, String body, Map> headers) { + HttpResponse response = mock(HttpResponse.class); + when(response.statusCode()).thenReturn(status); + when(response.body()).thenReturn(body); + when(response.headers()).thenReturn(HttpHeaders.of(headers, (a, b) -> true)); + return response; + } + + @BeforeEach + void setup() { + httpClient = mock(HttpClient.class); + tokenProvider = mock(ServiceManagerTokenProvider.class); + when(tokenProvider.getAccessToken()).thenReturn("test-token"); + // Use short poll timeout for tests + client = new ServiceManagerClient(CREDS, tokenProvider, httpClient, Duration.ofSeconds(1)); + } + + @Nested + class ReadOperations { + + @Test + void testGetOfferingIdReturnsId() throws Exception { + doReturn( + mockResponse( + 200, "{\"items\":[{\"id\":\"offering-123\",\"name\":\"objectstore\"}]}")) + .when(httpClient) + .send(any(), any()); + + String id = client.getOfferingId(); + + assertThat(id).isEqualTo("offering-123"); + } + + @Test + void testGetOfferingIdThrowsWhenNotFound() throws Exception { + doReturn(mockResponse(200, "{\"items\":[]}")).when(httpClient).send(any(), any()); + + assertThrows(ServiceManagerException.class, () -> client.getOfferingId()); + } + + @Test + void testGetPlanIdReturnsId() throws Exception { + doReturn(mockResponse(200, "{\"items\":[{\"id\":\"plan-456\"}]}")) + .when(httpClient) + .send(any(), any()); + + String id = client.getPlanId("offering-123"); + + assertThat(id).isEqualTo("plan-456"); + } + + @Test + void testGetPlanIdThrowsWhenNotFound() throws Exception { + doReturn(mockResponse(200, "{\"items\":[]}")).when(httpClient).send(any(), any()); + + assertThrows(ServiceManagerException.class, () -> client.getPlanId("offering-123")); + } + + @Test + void testGetBindingReturnsResultWhenFound() throws Exception { + String responseBody = + """ + {"items":[{ + "id":"binding-1", + "service_instance_id":"inst-1", + "credentials":{"host":"s3.aws.com","bucket":"tenant-bucket"} + }]}"""; + doReturn(mockResponse(200, responseBody)).when(httpClient).send(any(), any()); + + Optional result = + client.getBinding("tenant-1"); + + assertThat(result).isPresent(); + assertThat(result.get().bindingId()).isEqualTo("binding-1"); + assertThat(result.get().instanceId()).isEqualTo("inst-1"); + assertThat(result.get().credentials()).containsEntry("host", "s3.aws.com"); + } + + @Test + void testGetBindingReturnsEmptyWhenNotFound() throws Exception { + doReturn(mockResponse(200, "{\"items\":[]}")).when(httpClient).send(any(), any()); + + Optional result = + client.getBinding("tenant-1"); + + assertThat(result).isEmpty(); + } + + @Test + void testGetInstanceByTenantReturnsIdWhenFound() throws Exception { + doReturn(mockResponse(200, "{\"items\":[{\"id\":\"inst-1\"}]}")) + .when(httpClient) + .send(any(), any()); + + Optional result = client.getInstanceByTenant("tenant-1"); + + assertThat(result).hasValue("inst-1"); + } + + @Test + void testGetInstanceByTenantReturnsEmptyWhenNotFound() throws Exception { + doReturn(mockResponse(200, "{\"items\":[]}")).when(httpClient).send(any(), any()); + + Optional result = client.getInstanceByTenant("tenant-1"); + + assertThat(result).isEmpty(); + } + } + + @Nested + class WriteOperations { + + @Test + void testCreateInstanceSyncReturnsId() throws Exception { + doReturn(mockResponse(201, "{\"id\":\"inst-new\"}")).when(httpClient).send(any(), any()); + + String id = client.createInstance("tenant-1", "plan-123"); + + assertThat(id).isEqualTo("inst-new"); + } + + @Test + void testCreateInstanceAsyncPollsAndReturnsId() throws Exception { + // First call: async 202 with Location header + HttpResponse asyncResponse = + mockResponse( + 202, "", Map.of("Location", List.of("/v1/service_instances/inst-new/operations/op1"))); + // Second call (poll): succeeded + HttpResponse pollResponse = + mockResponse(200, "{\"id\":\"inst-new\",\"state\":\"succeeded\"}"); + + doReturn(asyncResponse).doReturn(pollResponse).when(httpClient).send(any(), any()); + + String id = client.createInstance("tenant-1", "plan-123"); + + assertThat(id).isEqualTo("inst-new"); + } + + @Test + void testCreateInstanceThrowsOnError() throws Exception { + doReturn(mockResponse(400, "Bad Request")).when(httpClient).send(any(), any()); + + assertThrows( + ServiceManagerException.class, () -> client.createInstance("tenant-1", "plan-123")); + } + + @Test + void testCreateBindingReturnsResult() throws Exception { + String body = + "{\"id\":\"bind-1\",\"service_instance_id\":\"inst-1\",\"credentials\":{\"bucket\":\"b1\"}}"; + doReturn(mockResponse(201, body)).when(httpClient).send(any(), any()); + + var result = client.createBinding("tenant-1", "inst-1"); + + assertThat(result.bindingId()).isEqualTo("bind-1"); + assertThat(result.credentials()).containsEntry("bucket", "b1"); + } + + @Test + void testCreateBindingThrowsOnError() throws Exception { + doReturn(mockResponse(400, "Bad Request")).when(httpClient).send(any(), any()); + + assertThrows( + ServiceManagerException.class, () -> client.createBinding("tenant-1", "inst-1")); + } + + @Test + void testDeleteBindingSucceeds() throws Exception { + doReturn(mockResponse(200, "")).when(httpClient).send(any(), any()); + + client.deleteBinding("bind-1"); // should not throw + } + + @Test + void testDeleteBindingThrowsOnError() throws Exception { + doReturn(mockResponse(500, "Internal Server Error")).when(httpClient).send(any(), any()); + + assertThrows(ServiceManagerException.class, () -> client.deleteBinding("bind-1")); + } + + @Test + void testDeleteInstanceSyncSucceeds() throws Exception { + doReturn(mockResponse(200, "")).when(httpClient).send(any(), any()); + + client.deleteInstance("inst-1"); // should not throw + } + + @Test + void testDeleteInstanceAsyncSucceeds() throws Exception { + HttpResponse asyncResponse = + mockResponse(202, "", Map.of("Location", List.of("/v1/service_instances/inst-1/op"))); + HttpResponse pollResponse = + mockResponse(200, "{\"state\":\"succeeded\"}"); + doReturn(asyncResponse).doReturn(pollResponse).when(httpClient).send(any(), any()); + + client.deleteInstance("inst-1"); // should not throw + } + + @Test + void testDeleteInstanceThrowsOnError() throws Exception { + doReturn(mockResponse(500, "fail")).when(httpClient).send(any(), any()); + + assertThrows(ServiceManagerException.class, () -> client.deleteInstance("inst-1")); + } + } + + @Nested + class Polling { + + @Test + void testPollTimesOut() throws Exception { + // Always return "in progress" — should timeout + doReturn(mockResponse(200, "{\"state\":\"in progress\"}")) + .when(httpClient) + .send(any(), any()); + + assertThrows( + ServiceManagerException.class, + () -> client.pollUntilDone("https://sm.example.com/op/1")); + } + + @Test + void testPollThrowsOnFailedState() throws Exception { + doReturn( + mockResponse( + 200, + "{\"state\":\"failed\",\"last_operation\":{\"state\":\"failed\",\"description\":\"quota exceeded\"}}")) + .when(httpClient) + .send(any(), any()); + + var ex = + assertThrows( + ServiceManagerException.class, + () -> client.pollUntilDone("https://sm.example.com/op/1")); + assertThat(ex.getMessage()).contains("quota exceeded"); + } + } + + @Nested + class ErrorHandling { + + @Test + void testHttpIoExceptionWrapped() throws Exception { + doThrow(new IOException("connection reset")).when(httpClient).send(any(), any()); + + assertThrows(ServiceManagerException.class, () -> client.getOfferingId()); + } + + @Test + void testHttpInterruptedExceptionWrapped() throws Exception { + doThrow(new InterruptedException("interrupted")).when(httpClient).send(any(), any()); + + assertThrows(ServiceManagerException.class, () -> client.getOfferingId()); + Thread.interrupted(); + } + } +} diff --git a/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/multitenancy/sm/ServiceManagerCredentialsTest.java b/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/multitenancy/sm/ServiceManagerCredentialsTest.java new file mode 100644 index 00000000..f0b7c505 --- /dev/null +++ b/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/multitenancy/sm/ServiceManagerCredentialsTest.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.multitenancy.sm; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.sap.cloud.environment.servicebinding.api.ServiceBinding; +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class ServiceManagerCredentialsTest { + + @Test + void testFromServiceBindingExtractsAllFields() { + ServiceBinding binding = mock(ServiceBinding.class); + Map creds = new HashMap<>(); + creds.put("sm_url", "https://sm.example.com"); + creds.put("url", "https://auth.example.com"); + creds.put("clientid", "my-client"); + creds.put("clientsecret", "my-secret"); + creds.put("certificate", "cert-pem"); + creds.put("key", "key-pem"); + creds.put("certurl", "https://cert-auth.example.com"); + when(binding.getCredentials()).thenReturn(creds); + + ServiceManagerCredentials result = ServiceManagerCredentials.fromServiceBinding(binding); + + assertThat(result.smUrl()).isEqualTo("https://sm.example.com"); + assertThat(result.authUrl()).isEqualTo("https://auth.example.com"); + assertThat(result.clientId()).isEqualTo("my-client"); + assertThat(result.clientSecret()).isEqualTo("my-secret"); + assertThat(result.certificate()).isEqualTo("cert-pem"); + assertThat(result.key()).isEqualTo("key-pem"); + assertThat(result.certUrl()).isEqualTo("https://cert-auth.example.com"); + } + + @Test + void testFromServiceBindingHandlesNullOptionalFields() { + ServiceBinding binding = mock(ServiceBinding.class); + Map creds = new HashMap<>(); + creds.put("sm_url", "https://sm.example.com"); + creds.put("url", "https://auth.example.com"); + creds.put("clientid", "my-client"); + creds.put("clientsecret", "my-secret"); + when(binding.getCredentials()).thenReturn(creds); + + ServiceManagerCredentials result = ServiceManagerCredentials.fromServiceBinding(binding); + + assertThat(result.certificate()).isNull(); + assertThat(result.key()).isNull(); + assertThat(result.certUrl()).isNull(); + } + + @Test + void testHasMtlsCredentialsReturnsTrueWhenBothPresent() { + var creds = + new ServiceManagerCredentials( + "url", "auth", "id", "secret", "cert-pem", "key-pem", "certurl"); + + assertThat(creds.hasMtlsCredentials()).isTrue(); + } + + @Test + void testHasMtlsCredentialsReturnsFalseWhenMissing() { + var creds = new ServiceManagerCredentials("url", "auth", "id", "secret", null, null, null); + + assertThat(creds.hasMtlsCredentials()).isFalse(); + } + + @Test + void testHasMtlsCredentialsReturnsFalseWhenEmpty() { + var creds = new ServiceManagerCredentials("url", "auth", "id", "secret", "", "", "certurl"); + + assertThat(creds.hasMtlsCredentials()).isFalse(); + } +} diff --git a/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/multitenancy/sm/ServiceManagerExceptionTest.java b/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/multitenancy/sm/ServiceManagerExceptionTest.java new file mode 100644 index 00000000..846555fe --- /dev/null +++ b/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/multitenancy/sm/ServiceManagerExceptionTest.java @@ -0,0 +1,28 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. + */ +package com.sap.cds.feature.attachments.oss.multitenancy.sm; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +class ServiceManagerExceptionTest { + + @Test + void testExceptionWithMessage() { + var ex = new ServiceManagerException("something failed"); + + assertThat(ex.getMessage()).isEqualTo("something failed"); + assertThat(ex.getCause()).isNull(); + } + + @Test + void testExceptionWithMessageAndCause() { + var cause = new RuntimeException("root"); + var ex = new ServiceManagerException("something failed", cause); + + assertThat(ex.getMessage()).isEqualTo("something failed"); + assertThat(ex.getCause()).isSameAs(cause); + } +} diff --git a/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/multitenancy/sm/ServiceManagerTokenProviderTest.java b/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/multitenancy/sm/ServiceManagerTokenProviderTest.java new file mode 100644 index 00000000..bd827ddc --- /dev/null +++ b/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/multitenancy/sm/ServiceManagerTokenProviderTest.java @@ -0,0 +1,95 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. + */ +package com.sap.cds.feature.attachments.oss.multitenancy.sm; + +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.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +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 java.io.IOException; +import java.net.http.HttpClient; +import java.net.http.HttpResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class ServiceManagerTokenProviderTest { + + private HttpClient httpClient; + private ServiceManagerTokenProvider tokenProvider; + + private static final ServiceManagerCredentials CREDS = + new ServiceManagerCredentials( + "https://sm.example.com", + "https://auth.example.com", + "my-client", + "my-secret", + null, + null, + null); + + @SuppressWarnings("unchecked") + private static HttpResponse mockResponse(int status, String body) { + HttpResponse response = mock(HttpResponse.class); + when(response.statusCode()).thenReturn(status); + when(response.body()).thenReturn(body); + return response; + } + + @BeforeEach + void setup() { + httpClient = mock(HttpClient.class); + tokenProvider = new ServiceManagerTokenProvider(CREDS, httpClient); + } + + @Test + void testGetAccessTokenFetchesFromAuthUrl() throws Exception { + doReturn(mockResponse(200, "{\"access_token\":\"tok123\",\"expires_in\":3600}")) + .when(httpClient) + .send(any(), any()); + + String token = tokenProvider.getAccessToken(); + + assertThat(token).isEqualTo("tok123"); + } + + @Test + void testGetAccessTokenCachesToken() throws Exception { + doReturn(mockResponse(200, "{\"access_token\":\"tok123\",\"expires_in\":3600}")) + .when(httpClient) + .send(any(), any()); + + tokenProvider.getAccessToken(); + tokenProvider.getAccessToken(); + + verify(httpClient, times(1)).send(any(), any()); + } + + @Test + void testGetAccessTokenThrowsOnHttpError() throws Exception { + doReturn(mockResponse(401, "Unauthorized")).when(httpClient).send(any(), any()); + + assertThrows(ServiceManagerException.class, () -> tokenProvider.getAccessToken()); + } + + @Test + void testGetAccessTokenThrowsOnIoException() throws Exception { + doThrow(new IOException("Connection refused")).when(httpClient).send(any(), any()); + + assertThrows(ServiceManagerException.class, () -> tokenProvider.getAccessToken()); + } + + @Test + void testGetAccessTokenThrowsOnInterruptedException() throws Exception { + doThrow(new InterruptedException("interrupted")).when(httpClient).send(any(), any()); + + assertThrows(ServiceManagerException.class, () -> tokenProvider.getAccessToken()); + Thread.interrupted(); + } +} From 1be2383935dbb71475ce1c1f8f9812e6479d3c3f Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Thu, 9 Apr 2026 14:10:56 +0200 Subject: [PATCH 7/8] test: Add integration tests for separate-bucket multitenancy - AbstractSeparateBucketOssStorageTest: 20 tests covering two-bucket CRUD, tenant isolation, cleanup, concurrency, and edge cases - AWS/Azure/GCP subclasses with per-tenant bucket env var support - SeparateBucketTenantLifecycleTest: 5 OData-level tests for subscribe/create/unsubscribe lifecycle flows --- .../mt/SeparateBucketTenantLifecycleTest.java | 267 +++++++++++ .../AbstractSeparateBucketOssStorageTest.java | 429 ++++++++++++++++++ .../oss/AwsSeparateBucketOssStorageTest.java | 65 +++ .../AzureSeparateBucketOssStorageTest.java | 54 +++ .../oss/GcpSeparateBucketOssStorageTest.java | 56 +++ 5 files changed, 871 insertions(+) create mode 100644 integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/mt/SeparateBucketTenantLifecycleTest.java create mode 100644 integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/mt/oss/AbstractSeparateBucketOssStorageTest.java create mode 100644 integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/mt/oss/AwsSeparateBucketOssStorageTest.java create mode 100644 integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/mt/oss/AzureSeparateBucketOssStorageTest.java create mode 100644 integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/mt/oss/GcpSeparateBucketOssStorageTest.java diff --git a/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/mt/SeparateBucketTenantLifecycleTest.java b/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/mt/SeparateBucketTenantLifecycleTest.java new file mode 100644 index 00000000..d407c266 --- /dev/null +++ b/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/mt/SeparateBucketTenantLifecycleTest.java @@ -0,0 +1,267 @@ +/* + * © 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 com.sap.cds.feature.attachments.integrationtests.mt.utils.SubscriptionEndpointClient; +import java.util.UUID; +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.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +/** + * Integration tests for the tenant lifecycle in separate-bucket multitenancy mode. Tests that + * subscribing a new tenant provisions storage, the tenant can create and read attachments, and + * unsubscribing cleans up data. + * + *

These tests target the subscribe/unsubscribe flow that will trigger the {@code + * ObjectStoreSubscribeHandler} and {@code ObjectStoreUnsubscribeHandler} once they are implemented + * for the separate-bucket mode. + * + *

Note: In the local test environment, the MTX sidecar manages tenant DB provisioning, while the + * object store provisioning (via Service Manager) would be handled by the OSS plugin's lifecycle + * handlers. + */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("local-with-tenants") +class SeparateBucketTenantLifecycleTest { + + 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); + } + + // --- Subscribe and access --- + + @Test + void subscribeTenant_thenCreateAndReadDocument() throws Exception { + subscriptionEndpointClient.subscribeTenant("tenant-3"); + + String title = "SeparateBucket-Doc-" + UUID.randomUUID(); + + // Create document in newly subscribed tenant + client + .perform( + post(DOCUMENTS_URL) + .with(httpBasic("user-in-tenant-3", "")) + .contentType(MediaType.APPLICATION_JSON) + .content("{ \"title\": \"" + title + "\" }")) + .andExpect(status().isCreated()); + + // Read back + String response = + client + .perform(get(DOCUMENTS_URL).with(httpBasic("user-in-tenant-3", ""))) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(); + + JsonNode values = objectMapper.readTree(response).path("value"); + boolean found = false; + for (JsonNode node : values) { + if (title.equals(node.get("title").asText(""))) { + found = true; + } + } + assertThat(found).as("Created document should be visible to the tenant").isTrue(); + } + + // --- Subscribe, create data, unsubscribe, resubscribe — data should be gone --- + + @Test + void subscribeCreateUnsubscribeResubscribe_dataIsGone() throws Exception { + subscriptionEndpointClient.subscribeTenant("tenant-3"); + + String title = "Ephemeral-" + UUID.randomUUID(); + + // Create document + client + .perform( + post(DOCUMENTS_URL) + .with(httpBasic("user-in-tenant-3", "")) + .contentType(MediaType.APPLICATION_JSON) + .content("{ \"title\": \"" + title + "\" }")) + .andExpect(status().isCreated()); + + // Unsubscribe — should clean up data + subscriptionEndpointClient.unsubscribeTenant("tenant-3"); + + // Resubscribe — fresh tenant + subscriptionEndpointClient.subscribeTenant("tenant-3"); + + // Read — previously created document should NOT exist + String response = + client + .perform(get(DOCUMENTS_URL).with(httpBasic("user-in-tenant-3", ""))) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(); + + JsonNode values = objectMapper.readTree(response).path("value"); + for (JsonNode node : values) { + assertThat(node.get("title").asText("")) + .as("Document from previous subscription should be cleaned up") + .isNotEqualTo(title); + } + } + + // --- Unsubscribe does not affect other tenants --- + + @Test + void unsubscribeTenant3_doesNotAffectTenant1() throws Exception { + // Create document in tenant-1 (pre-subscribed via mock tenants) + String t1Title = "T1-Survives-" + UUID.randomUUID(); + client + .perform( + post(DOCUMENTS_URL) + .with(httpBasic("user-in-tenant-1", "")) + .contentType(MediaType.APPLICATION_JSON) + .content("{ \"title\": \"" + t1Title + "\" }")) + .andExpect(status().isCreated()); + + // Subscribe and unsubscribe tenant-3 + subscriptionEndpointClient.subscribeTenant("tenant-3"); + subscriptionEndpointClient.unsubscribeTenant("tenant-3"); + + // Tenant-1 data should still be there + String response = + client + .perform(get(DOCUMENTS_URL).with(httpBasic("user-in-tenant-1", ""))) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(); + + JsonNode values = objectMapper.readTree(response).path("value"); + boolean found = false; + for (JsonNode node : values) { + if (t1Title.equals(node.get("title").asText(""))) { + found = true; + } + } + assertThat(found).as("Tenant-1 data should survive tenant-3 unsubscription").isTrue(); + } + + // --- Multiple tenants can operate concurrently --- + + @Test + void multipleTenantsOperateConcurrently() throws Exception { + String t1Title = "Concurrent-T1-" + UUID.randomUUID(); + String t2Title = "Concurrent-T2-" + UUID.randomUUID(); + + // Create in both tenants + client + .perform( + post(DOCUMENTS_URL) + .with(httpBasic("user-in-tenant-1", "")) + .contentType(MediaType.APPLICATION_JSON) + .content("{ \"title\": \"" + t1Title + "\" }")) + .andExpect(status().isCreated()); + + client + .perform( + post(DOCUMENTS_URL) + .with(httpBasic("user-in-tenant-2", "")) + .contentType(MediaType.APPLICATION_JSON) + .content("{ \"title\": \"" + t2Title + "\" }")) + .andExpect(status().isCreated()); + + // Each tenant sees only their own + assertTenantSeesDocument("user-in-tenant-1", t1Title); + assertTenantDoesNotSeeDocument("user-in-tenant-1", t2Title); + assertTenantSeesDocument("user-in-tenant-2", t2Title); + assertTenantDoesNotSeeDocument("user-in-tenant-2", t1Title); + } + + // --- Subscribe same tenant twice (idempotent) --- + + @Test + void subscribeSameTenantTwice_isIdempotent() throws Exception { + subscriptionEndpointClient.subscribeTenant("tenant-3"); + + String title = "Idempotent-" + UUID.randomUUID(); + client + .perform( + post(DOCUMENTS_URL) + .with(httpBasic("user-in-tenant-3", "")) + .contentType(MediaType.APPLICATION_JSON) + .content("{ \"title\": \"" + title + "\" }")) + .andExpect(status().isCreated()); + + // Subscribe again — should not lose existing data + subscriptionEndpointClient.subscribeTenant("tenant-3"); + + assertTenantSeesDocument("user-in-tenant-3", title); + } + + @AfterEach + void tearDown() { + try { + subscriptionEndpointClient.unsubscribeTenant("tenant-3"); + } catch (Exception ignored) { + // best effort cleanup + } + } + + // --- Helper methods --- + + private void assertTenantSeesDocument(String user, String title) throws Exception { + String response = + client + .perform(get(DOCUMENTS_URL).with(httpBasic(user, ""))) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(); + + JsonNode values = objectMapper.readTree(response).path("value"); + boolean found = false; + for (JsonNode node : values) { + if (title.equals(node.get("title").asText(""))) { + found = true; + } + } + assertThat(found).as(user + " should see document: " + title).isTrue(); + } + + private void assertTenantDoesNotSeeDocument(String user, String title) throws Exception { + String response = + client + .perform(get(DOCUMENTS_URL).with(httpBasic(user, ""))) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(); + + JsonNode values = objectMapper.readTree(response).path("value"); + for (JsonNode node : values) { + assertThat(node.get("title").asText("")) + .as(user + " should NOT see document: " + title) + .isNotEqualTo(title); + } + } +} diff --git a/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/mt/oss/AbstractSeparateBucketOssStorageTest.java b/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/mt/oss/AbstractSeparateBucketOssStorageTest.java new file mode 100644 index 00000000..ab2de533 --- /dev/null +++ b/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/mt/oss/AbstractSeparateBucketOssStorageTest.java @@ -0,0 +1,429 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. + */ +package com.sap.cds.feature.attachments.integrationtests.mt.oss; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.sap.cds.feature.attachments.oss.client.OSClient; +import com.sap.cds.feature.attachments.oss.client.OSClientFactory; +import com.sap.cloud.environment.servicebinding.api.ServiceBinding; +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; + +/** + * Abstract base class for separate-bucket multitenancy OSS integration tests. In separate-bucket + * mode, each tenant gets its own dedicated bucket. Object keys are plain content IDs (no tenant + * prefix) since tenant isolation is achieved at the bucket level. + * + *

Subclasses provide two cloud-specific {@link ServiceBinding}s (one per tenant) via {@link + * #getTenant1ServiceBinding()} and {@link #getTenant2ServiceBinding()}. + * + *

These tests verify the contract that the future {@code SeparateOSClientProvider} must satisfy: + * each tenant resolves to its own independent {@link OSClient} with its own bucket, and operations + * in one tenant's bucket are completely invisible to the other. + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +abstract class AbstractSeparateBucketOssStorageTest { + + private static final String MIME_TYPE = "text/plain"; + + private final String testRunId = String.valueOf(System.currentTimeMillis()); + + private ExecutorService executor; + private OSClient tenant1Client; + private OSClient tenant2Client; + private final Set tenant1CreatedKeys = new LinkedHashSet<>(); + private final Set tenant2CreatedKeys = new LinkedHashSet<>(); + + /** + * Returns a {@link ServiceBinding} for tenant-1's dedicated bucket, or {@code null} if + * credentials are not available. + */ + protected abstract ServiceBinding getTenant1ServiceBinding(); + + /** + * Returns a {@link ServiceBinding} for tenant-2's dedicated bucket, or {@code null} if + * credentials are not available. + */ + protected abstract ServiceBinding getTenant2ServiceBinding(); + + /** Returns the cloud provider name for display in skip messages. */ + protected abstract String getProviderName(); + + @BeforeAll + void setUp() { + ServiceBinding binding1 = getTenant1ServiceBinding(); + ServiceBinding binding2 = getTenant2ServiceBinding(); + Assumptions.assumeTrue( + binding1 != null && binding2 != null, + getProviderName() + + " separate-bucket credentials not available — skipping tests. " + + "Two distinct buckets/containers are required."); + + executor = Executors.newCachedThreadPool(); + tenant1Client = OSClientFactory.create(binding1, executor); + tenant2Client = OSClientFactory.create(binding2, executor); + } + + @AfterAll + void tearDown() { + try { + cleanupKeys(tenant1Client, tenant1CreatedKeys); + cleanupKeys(tenant2Client, tenant2CreatedKeys); + } finally { + if (executor != null) { + executor.shutdownNow(); + } + } + } + + // --- Basic CRUD per tenant --- + + @Test + void createAndReadInTenant1Bucket() throws Exception { + String contentId = uniqueId("crud-t1"); + uploadContent(tenant1Client, tenant1CreatedKeys, contentId, "hello from tenant 1 bucket"); + assertThat(downloadContent(tenant1Client, contentId)) + .isEqualTo("hello from tenant 1 bucket"); + } + + @Test + void createAndReadInTenant2Bucket() throws Exception { + String contentId = uniqueId("crud-t2"); + uploadContent(tenant2Client, tenant2CreatedKeys, contentId, "hello from tenant 2 bucket"); + assertThat(downloadContent(tenant2Client, contentId)) + .isEqualTo("hello from tenant 2 bucket"); + } + + // --- Tenant isolation: cross-bucket reads fail --- + + @Test + void readFromTenant2Bucket_whenObjectOnlyInTenant1_fails() throws Exception { + String contentId = uniqueId("isolation-t1-only"); + uploadContent(tenant1Client, tenant1CreatedKeys, contentId, "secret data in tenant 1"); + + // Attempting to read the same contentId from tenant-2's bucket should fail — + // the object doesn't exist there. + assertThatThrownBy(() -> downloadContent(tenant2Client, contentId)) + .isInstanceOf(Exception.class); + } + + @Test + void readFromTenant1Bucket_whenObjectOnlyInTenant2_fails() throws Exception { + String contentId = uniqueId("isolation-t2-only"); + uploadContent(tenant2Client, tenant2CreatedKeys, contentId, "secret data in tenant 2"); + + assertThatThrownBy(() -> downloadContent(tenant1Client, contentId)) + .isInstanceOf(Exception.class); + } + + // --- Same contentId in both buckets stores different data --- + + @Test + void sameContentIdInBothBuckets_storesDifferentData() throws Exception { + String contentId = uniqueId("same-id-different-data"); + uploadContent(tenant1Client, tenant1CreatedKeys, contentId, "tenant-1-version"); + uploadContent(tenant2Client, tenant2CreatedKeys, contentId, "tenant-2-version"); + + assertThat(downloadContent(tenant1Client, contentId)).isEqualTo("tenant-1-version"); + assertThat(downloadContent(tenant2Client, contentId)).isEqualTo("tenant-2-version"); + } + + // --- Delete isolation --- + + @Test + void deleteInTenant1_doesNotAffectTenant2() throws Exception { + String contentId = uniqueId("delete-isolation"); + uploadContent(tenant1Client, tenant1CreatedKeys, contentId, "t1 data"); + uploadContent(tenant2Client, tenant2CreatedKeys, contentId, "t2 data"); + + tenant1Client.deleteContent(contentId).get(); + tenant1CreatedKeys.remove(contentId); + + assertThatThrownBy(() -> downloadContent(tenant1Client, contentId)) + .isInstanceOf(Exception.class); + assertThat(downloadContent(tenant2Client, contentId)).isEqualTo("t2 data"); + } + + @Test + void deleteInTenant2_doesNotAffectTenant1() throws Exception { + String contentId = uniqueId("delete-isolation-rev"); + uploadContent(tenant1Client, tenant1CreatedKeys, contentId, "t1 data"); + uploadContent(tenant2Client, tenant2CreatedKeys, contentId, "t2 data"); + + tenant2Client.deleteContent(contentId).get(); + tenant2CreatedKeys.remove(contentId); + + assertThat(downloadContent(tenant1Client, contentId)).isEqualTo("t1 data"); + assertThatThrownBy(() -> downloadContent(tenant2Client, contentId)) + .isInstanceOf(Exception.class); + } + + // --- Delete then re-read fails --- + + @Test + void deleteObject_subsequentReadFails() throws Exception { + String contentId = uniqueId("delete-then-read"); + uploadContent(tenant1Client, tenant1CreatedKeys, contentId, "will be deleted"); + + tenant1Client.deleteContent(contentId).get(); + tenant1CreatedKeys.remove(contentId); + + assertThatThrownBy(() -> downloadContent(tenant1Client, contentId)) + .isInstanceOf(Exception.class); + } + + // --- Update flow (delete + re-create) --- + + @Test + void updateFlow_deleteAndRecreateWithNewContent() throws Exception { + String contentId = uniqueId("update-flow"); + uploadContent(tenant1Client, tenant1CreatedKeys, contentId, "version 1"); + assertThat(downloadContent(tenant1Client, contentId)).isEqualTo("version 1"); + + tenant1Client.deleteContent(contentId).get(); + tenant1CreatedKeys.remove(contentId); + + uploadContent(tenant1Client, tenant1CreatedKeys, contentId, "version 2"); + assertThat(downloadContent(tenant1Client, contentId)).isEqualTo("version 2"); + } + + // --- Multiple objects per tenant bucket --- + + @Test + void multipleObjectsInSameBucket() throws Exception { + for (int i = 0; i < 5; i++) { + String contentId = uniqueId("multi-" + i); + uploadContent(tenant1Client, tenant1CreatedKeys, contentId, "file-" + i); + assertThat(downloadContent(tenant1Client, contentId)).isEqualTo("file-" + i); + } + } + + // --- Tenant cleanup: deleteContentByPrefix("") clears entire bucket --- + + @Test + void tenantCleanup_deleteByEmptyPrefixRemovesAllObjects() throws Exception { + // In separate-bucket mode, tenant unsubscribe calls deleteContentByPrefix("") to empty the + // bucket before deleting the SM instance. Use a dedicated sub-prefix to avoid disturbing + // objects from other tests running concurrently. + String subPrefix = "cleanup-" + testRunId + "/"; + + List keys = new ArrayList<>(); + for (int i = 0; i < 4; i++) { + String key = subPrefix + "obj-" + i; + keys.add(key); + uploadContent(tenant1Client, tenant1CreatedKeys, key, "cleanup data " + i); + } + + // Verify objects exist + for (int i = 0; i < keys.size(); i++) { + assertThat(downloadContent(tenant1Client, keys.get(i))).isEqualTo("cleanup data " + i); + } + + // Delete all objects with the sub-prefix + tenant1Client.deleteContentByPrefix(subPrefix).get(); + tenant1CreatedKeys.removeAll(keys); + + // All objects should be gone + for (String key : keys) { + assertThatThrownBy(() -> downloadContent(tenant1Client, key)) + .as("Object should have been deleted by prefix cleanup: " + key) + .isInstanceOf(Exception.class); + } + } + + @Test + void tenantCleanup_doesNotAffectOtherTenantBucket() throws Exception { + String subPrefix = "cleanup-cross-" + testRunId + "/"; + String t1Key = subPrefix + "t1-obj"; + String t2Key = subPrefix + "t2-obj"; + + uploadContent(tenant1Client, tenant1CreatedKeys, t1Key, "t1 cleanup data"); + uploadContent(tenant2Client, tenant2CreatedKeys, t2Key, "t2 should survive"); + + // Clean up tenant-1's bucket + tenant1Client.deleteContentByPrefix(subPrefix).get(); + tenant1CreatedKeys.remove(t1Key); + + // Tenant-1 object gone + assertThatThrownBy(() -> downloadContent(tenant1Client, t1Key)) + .isInstanceOf(Exception.class); + + // Tenant-2 object survives — different bucket entirely + assertThat(downloadContent(tenant2Client, t2Key)).isEqualTo("t2 should survive"); + } + + // --- Large content --- + + @Test + void largeContentUploadAndDownload() throws Exception { + String contentId = uniqueId("large-content"); + // 1 MB of data + String largeContent = "A".repeat(1024 * 1024); + uploadContent(tenant1Client, tenant1CreatedKeys, contentId, largeContent); + assertThat(downloadContent(tenant1Client, contentId)).isEqualTo(largeContent); + } + + // --- Content types --- + + @Test + void uploadWithDifferentMimeType() throws Exception { + String contentId = uniqueId("json-content"); + String jsonContent = "{\"key\": \"value\", \"number\": 42}"; + InputStream stream = new ByteArrayInputStream(jsonContent.getBytes(StandardCharsets.UTF_8)); + tenant1Client.uploadContent(stream, contentId, "application/json").get(); + tenant1CreatedKeys.add(contentId); + + assertThat(downloadContent(tenant1Client, contentId)).isEqualTo(jsonContent); + } + + // --- Overwrite existing object --- + + @Test + void overwriteExistingObject_readsNewContent() throws Exception { + String contentId = uniqueId("overwrite"); + uploadContent(tenant1Client, tenant1CreatedKeys, contentId, "original"); + assertThat(downloadContent(tenant1Client, contentId)).isEqualTo("original"); + + // Overwrite with new content (same key) + uploadContent(tenant1Client, tenant1CreatedKeys, contentId, "overwritten"); + assertThat(downloadContent(tenant1Client, contentId)).isEqualTo("overwritten"); + } + + // --- Concurrent operations across tenants --- + + @Test + void concurrentUploadsAcrossTenants() throws Exception { + String contentId1 = uniqueId("concurrent-t1"); + String contentId2 = uniqueId("concurrent-t2"); + + // Upload to both tenants simultaneously + var future1 = + tenant1Client.uploadContent( + new ByteArrayInputStream("concurrent-t1-data".getBytes(StandardCharsets.UTF_8)), + contentId1, + MIME_TYPE); + var future2 = + tenant2Client.uploadContent( + new ByteArrayInputStream("concurrent-t2-data".getBytes(StandardCharsets.UTF_8)), + contentId2, + MIME_TYPE); + + future1.get(); + future2.get(); + tenant1CreatedKeys.add(contentId1); + tenant2CreatedKeys.add(contentId2); + + assertThat(downloadContent(tenant1Client, contentId1)).isEqualTo("concurrent-t1-data"); + assertThat(downloadContent(tenant2Client, contentId2)).isEqualTo("concurrent-t2-data"); + } + + // --- Empty content --- + + @Test + void emptyContentUploadAndDownload() throws Exception { + String contentId = uniqueId("empty-content"); + uploadContent(tenant1Client, tenant1CreatedKeys, contentId, ""); + assertThat(downloadContent(tenant1Client, contentId)).isEmpty(); + } + + // --- Special characters in content ID --- + + @Test + void contentIdWithSpecialCharacters() throws Exception { + // Content IDs are typically UUIDs, but test that the storage handles various safe characters + String contentId = uniqueId("special_chars-test.v2"); + uploadContent(tenant1Client, tenant1CreatedKeys, contentId, "special chars content"); + assertThat(downloadContent(tenant1Client, contentId)).isEqualTo("special chars content"); + } + + // --- Batch operations: multiple uploads then reads --- + + @Test + void batchUploadThenBatchRead() throws Exception { + Map entries = + Map.of( + uniqueId("batch-0"), "batch content 0", + uniqueId("batch-1"), "batch content 1", + uniqueId("batch-2"), "batch content 2"); + + // Upload all + for (var entry : entries.entrySet()) { + uploadContent(tenant1Client, tenant1CreatedKeys, entry.getKey(), entry.getValue()); + } + + // Read all back + for (var entry : entries.entrySet()) { + assertThat(downloadContent(tenant1Client, entry.getKey())).isEqualTo(entry.getValue()); + } + } + + // --- Delete non-existent object --- + + @Test + void deleteNonExistentObject_doesNotThrow() throws Exception { + String contentId = uniqueId("never-created"); + // Most cloud providers treat delete of non-existent objects as a no-op (idempotent). + // This test documents that expected behavior. + tenant1Client.deleteContent(contentId).get(); + } + + // --- Read non-existent object --- + + @Test + void readNonExistentObject_fails() { + String contentId = uniqueId("does-not-exist"); + assertThatThrownBy(() -> downloadContent(tenant1Client, contentId)) + .isInstanceOf(Exception.class); + } + + // --- Helper methods --- + + private String uniqueId(String label) { + return "sep-test-" + label + "-" + testRunId; + } + + private void uploadContent( + OSClient client, Set tracker, String contentId, String content) throws Exception { + InputStream stream = new ByteArrayInputStream(content.getBytes(StandardCharsets.UTF_8)); + client.uploadContent(stream, contentId, MIME_TYPE).get(); + tracker.add(contentId); + } + + private String downloadContent(OSClient client, String contentId) throws Exception { + try (InputStream stream = client.readContent(contentId).get()) { + if (stream == null) { + throw new RuntimeException("Content not found for key: " + contentId); + } + return new String(stream.readAllBytes(), StandardCharsets.UTF_8); + } + } + + private void cleanupKeys(OSClient client, Set keys) { + if (client == null) { + return; + } + for (String key : keys) { + try { + client.deleteContent(key).get(); + } catch (Exception ignored) { + // best effort cleanup + } + } + } +} diff --git a/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/mt/oss/AwsSeparateBucketOssStorageTest.java b/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/mt/oss/AwsSeparateBucketOssStorageTest.java new file mode 100644 index 00000000..43cc420b --- /dev/null +++ b/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/mt/oss/AwsSeparateBucketOssStorageTest.java @@ -0,0 +1,65 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. + */ +package com.sap.cds.feature.attachments.integrationtests.mt.oss; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.sap.cloud.environment.servicebinding.api.ServiceBinding; +import java.util.HashMap; + +/** + * Runs the separate-bucket multitenancy integration tests against real AWS S3 instances. Requires + * TWO distinct S3 buckets (one per simulated tenant). Skipped automatically if the required + * environment variables are not set. + * + *

Required environment variables for tenant-1 bucket: {@code AWS_S3_HOST}, {@code + * AWS_S3_BUCKET_T1}, {@code AWS_S3_REGION}, {@code AWS_S3_ACCESS_KEY_ID}, {@code + * AWS_S3_SECRET_ACCESS_KEY}. + * + *

Required environment variables for tenant-2 bucket: {@code AWS_S3_BUCKET_T2} (uses the same + * host, region, and credentials as tenant-1 but a different bucket). + */ +class AwsSeparateBucketOssStorageTest extends AbstractSeparateBucketOssStorageTest { + + @Override + protected ServiceBinding getTenant1ServiceBinding() { + return buildBinding(System.getenv("AWS_S3_BUCKET_T1")); + } + + @Override + protected ServiceBinding getTenant2ServiceBinding() { + return buildBinding(System.getenv("AWS_S3_BUCKET_T2")); + } + + @Override + protected String getProviderName() { + return "AWS S3 (separate buckets)"; + } + + private static ServiceBinding buildBinding(String bucket) { + String host = System.getenv("AWS_S3_HOST"); + String region = System.getenv("AWS_S3_REGION"); + String accessKeyId = System.getenv("AWS_S3_ACCESS_KEY_ID"); + String secretAccessKey = System.getenv("AWS_S3_SECRET_ACCESS_KEY"); + + if (host == null + || bucket == null + || region == null + || accessKeyId == null + || secretAccessKey == null) { + return null; + } + + ServiceBinding binding = mock(ServiceBinding.class); + HashMap creds = new HashMap<>(); + creds.put("host", host); + creds.put("bucket", bucket); + creds.put("region", region); + creds.put("access_key_id", accessKeyId); + creds.put("secret_access_key", secretAccessKey); + when(binding.getCredentials()).thenReturn(creds); + return binding; + } +} diff --git a/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/mt/oss/AzureSeparateBucketOssStorageTest.java b/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/mt/oss/AzureSeparateBucketOssStorageTest.java new file mode 100644 index 00000000..dcecd748 --- /dev/null +++ b/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/mt/oss/AzureSeparateBucketOssStorageTest.java @@ -0,0 +1,54 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. + */ +package com.sap.cds.feature.attachments.integrationtests.mt.oss; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.sap.cloud.environment.servicebinding.api.ServiceBinding; +import java.util.HashMap; + +/** + * Runs the separate-bucket multitenancy integration tests against real Azure Blob Storage + * instances. Requires TWO distinct containers (one per simulated tenant). Skipped automatically if + * the required environment variables are not set. + * + *

Required environment variables for tenant-1: {@code AZURE_CONTAINER_URI_T1}, {@code + * AZURE_SAS_TOKEN_T1}. + * + *

Required environment variables for tenant-2: {@code AZURE_CONTAINER_URI_T2}, {@code + * AZURE_SAS_TOKEN_T2}. + */ +class AzureSeparateBucketOssStorageTest extends AbstractSeparateBucketOssStorageTest { + + @Override + protected ServiceBinding getTenant1ServiceBinding() { + return buildBinding( + System.getenv("AZURE_CONTAINER_URI_T1"), System.getenv("AZURE_SAS_TOKEN_T1")); + } + + @Override + protected ServiceBinding getTenant2ServiceBinding() { + return buildBinding( + System.getenv("AZURE_CONTAINER_URI_T2"), System.getenv("AZURE_SAS_TOKEN_T2")); + } + + @Override + protected String getProviderName() { + return "Azure Blob Storage (separate containers)"; + } + + private static ServiceBinding buildBinding(String containerUri, String sasToken) { + if (containerUri == null || sasToken == null) { + return null; + } + + ServiceBinding binding = mock(ServiceBinding.class); + HashMap creds = new HashMap<>(); + creds.put("container_uri", containerUri); + creds.put("sas_token", sasToken); + when(binding.getCredentials()).thenReturn(creds); + return binding; + } +} diff --git a/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/mt/oss/GcpSeparateBucketOssStorageTest.java b/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/mt/oss/GcpSeparateBucketOssStorageTest.java new file mode 100644 index 00000000..82b45b87 --- /dev/null +++ b/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/mt/oss/GcpSeparateBucketOssStorageTest.java @@ -0,0 +1,56 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. + */ +package com.sap.cds.feature.attachments.integrationtests.mt.oss; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.sap.cloud.environment.servicebinding.api.ServiceBinding; +import java.util.HashMap; + +/** + * Runs the separate-bucket multitenancy integration tests against real Google Cloud Storage + * instances. Requires TWO distinct GCS buckets (one per simulated tenant). Skipped automatically if + * the required environment variables are not set. + * + *

Required environment variables for tenant-1 bucket: {@code GS_BUCKET_T1}, {@code + * GS_PROJECT_ID}, {@code GS_BASE_64_ENCODED_PRIVATE_KEY_DATA}. + * + *

Required environment variables for tenant-2 bucket: {@code GS_BUCKET_T2} (uses the same + * project ID and credentials as tenant-1 but a different bucket). + */ +class GcpSeparateBucketOssStorageTest extends AbstractSeparateBucketOssStorageTest { + + @Override + protected ServiceBinding getTenant1ServiceBinding() { + return buildBinding(System.getenv("GS_BUCKET_T1")); + } + + @Override + protected ServiceBinding getTenant2ServiceBinding() { + return buildBinding(System.getenv("GS_BUCKET_T2")); + } + + @Override + protected String getProviderName() { + return "Google Cloud Storage (separate buckets)"; + } + + private static ServiceBinding buildBinding(String bucket) { + String projectId = System.getenv("GS_PROJECT_ID"); + String base64EncodedPrivateKeyData = System.getenv("GS_BASE_64_ENCODED_PRIVATE_KEY_DATA"); + + if (bucket == null || projectId == null || base64EncodedPrivateKeyData == null) { + return null; + } + + ServiceBinding binding = mock(ServiceBinding.class); + HashMap creds = new HashMap<>(); + creds.put("bucket", bucket); + creds.put("projectId", projectId); + creds.put("base64EncodedPrivateKeyData", base64EncodedPrivateKeyData); + when(binding.getCredentials()).thenReturn(creds); + return binding; + } +} From 4afdb25eeaccdda4cfa5a45ebec15a0c67aad49c Mon Sep 17 00:00:00 2001 From: Marvin Lindner Date: Thu, 9 Apr 2026 14:12:02 +0200 Subject: [PATCH 8/8] spotless --- .../AbstractSeparateBucketOssStorageTest.java | 13 +++++-------- .../oss/configuration/Registration.java | 3 +-- .../ObjectStoreLifecycleHandler.java | 3 ++- .../multitenancy/sm/ServiceManagerBinding.java | 3 +-- .../multitenancy/sm/ServiceManagerClient.java | 7 ++++--- .../sm/ServiceManagerTokenProvider.java | 3 +-- .../OSSAttachmentsServiceHandlerTest.java | 10 +++++++--- .../ObjectStoreLifecycleHandlerTest.java | 4 +--- .../SeparateOSClientProviderTest.java | 13 +++++++++++-- .../sm/ServiceManagerClientTest.java | 16 +++++++--------- 10 files changed, 40 insertions(+), 35 deletions(-) diff --git a/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/mt/oss/AbstractSeparateBucketOssStorageTest.java b/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/mt/oss/AbstractSeparateBucketOssStorageTest.java index ab2de533..b7d6e8a5 100644 --- a/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/mt/oss/AbstractSeparateBucketOssStorageTest.java +++ b/integration-tests/mtx-local/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/mt/oss/AbstractSeparateBucketOssStorageTest.java @@ -98,16 +98,14 @@ void tearDown() { void createAndReadInTenant1Bucket() throws Exception { String contentId = uniqueId("crud-t1"); uploadContent(tenant1Client, tenant1CreatedKeys, contentId, "hello from tenant 1 bucket"); - assertThat(downloadContent(tenant1Client, contentId)) - .isEqualTo("hello from tenant 1 bucket"); + assertThat(downloadContent(tenant1Client, contentId)).isEqualTo("hello from tenant 1 bucket"); } @Test void createAndReadInTenant2Bucket() throws Exception { String contentId = uniqueId("crud-t2"); uploadContent(tenant2Client, tenant2CreatedKeys, contentId, "hello from tenant 2 bucket"); - assertThat(downloadContent(tenant2Client, contentId)) - .isEqualTo("hello from tenant 2 bucket"); + assertThat(downloadContent(tenant2Client, contentId)).isEqualTo("hello from tenant 2 bucket"); } // --- Tenant isolation: cross-bucket reads fail --- @@ -261,8 +259,7 @@ void tenantCleanup_doesNotAffectOtherTenantBucket() throws Exception { tenant1CreatedKeys.remove(t1Key); // Tenant-1 object gone - assertThatThrownBy(() -> downloadContent(tenant1Client, t1Key)) - .isInstanceOf(Exception.class); + assertThatThrownBy(() -> downloadContent(tenant1Client, t1Key)).isInstanceOf(Exception.class); // Tenant-2 object survives — different bucket entirely assertThat(downloadContent(tenant2Client, t2Key)).isEqualTo("t2 should survive"); @@ -398,8 +395,8 @@ private String uniqueId(String label) { return "sep-test-" + label + "-" + testRunId; } - private void uploadContent( - OSClient client, Set tracker, String contentId, String content) throws Exception { + private void uploadContent(OSClient client, Set tracker, String contentId, String content) + throws Exception { InputStream stream = new ByteArrayInputStream(content.getBytes(StandardCharsets.UTF_8)); client.uploadContent(stream, contentId, MIME_TYPE).get(); tracker.add(contentId); 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 b57a32a2..aa522222 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 @@ -149,8 +149,7 @@ private static Optional getOSBinding(CdsEnvironment environment) private static Optional getServiceManagerBinding(CdsEnvironment environment) { return environment .getServiceBindings() - .filter( - b -> b.getServiceName().map(name -> name.equals("service-manager")).orElse(false)) + .filter(b -> b.getServiceName().map(name -> name.equals("service-manager")).orElse(false)) .findFirst(); } diff --git a/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/multitenancy/ObjectStoreLifecycleHandler.java b/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/multitenancy/ObjectStoreLifecycleHandler.java index ef85925d..8afc8d02 100644 --- a/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/multitenancy/ObjectStoreLifecycleHandler.java +++ b/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/multitenancy/ObjectStoreLifecycleHandler.java @@ -80,7 +80,8 @@ public void onTenantUnsubscribe(String tenantId) { var bindingResult = smClient.getBinding(tenantId); if (bindingResult.isEmpty()) { - logger.warn("No binding found for tenant {} during unsubscribe, nothing to clean up", tenantId); + logger.warn( + "No binding found for tenant {} during unsubscribe, nothing to clean up", tenantId); clientProvider.evict(tenantId); return; } diff --git a/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/multitenancy/sm/ServiceManagerBinding.java b/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/multitenancy/sm/ServiceManagerBinding.java index 247ed752..3132f451 100644 --- a/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/multitenancy/sm/ServiceManagerBinding.java +++ b/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/multitenancy/sm/ServiceManagerBinding.java @@ -12,8 +12,7 @@ /** * Adapts a Service Manager binding response (JSON credentials map) into the {@link ServiceBinding} - * interface expected by {@link - * com.sap.cds.feature.attachments.oss.client.OSClientFactory#create}. + * interface expected by {@link com.sap.cds.feature.attachments.oss.client.OSClientFactory#create}. */ public class ServiceManagerBinding implements ServiceBinding { diff --git a/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/multitenancy/sm/ServiceManagerClient.java b/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/multitenancy/sm/ServiceManagerClient.java index c5dbc965..0aa01d84 100644 --- a/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/multitenancy/sm/ServiceManagerClient.java +++ b/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/multitenancy/sm/ServiceManagerClient.java @@ -100,8 +100,7 @@ public Optional getBinding(String tenantId) { String bindingId = binding.get("id").asText(); String instanceId = binding.get("service_instance_id").asText(); @SuppressWarnings("unchecked") - Map credentials = - MAPPER.convertValue(binding.get("credentials"), Map.class); + Map credentials = MAPPER.convertValue(binding.get("credentials"), Map.class); return Optional.of(new ServiceManagerBindingResult(bindingId, instanceId, credentials)); } @@ -238,7 +237,9 @@ JsonNode pollUntilDone(String url) { JsonNode json = sendGet(url); String state = - json.has("state") ? json.get("state").asText() : json.path("last_operation").path("state").asText(""); + json.has("state") + ? json.get("state").asText() + : json.path("last_operation").path("state").asText(""); if ("succeeded".equals(state)) { return json; } else if ("failed".equals(state)) { diff --git a/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/multitenancy/sm/ServiceManagerTokenProvider.java b/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/multitenancy/sm/ServiceManagerTokenProvider.java index 1d23556e..0afda09d 100644 --- a/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/multitenancy/sm/ServiceManagerTokenProvider.java +++ b/storage-targets/cds-feature-attachments-oss/src/main/java/com/sap/cds/feature/attachments/oss/multitenancy/sm/ServiceManagerTokenProvider.java @@ -33,8 +33,7 @@ public class ServiceManagerTokenProvider { private String cachedToken; private Instant tokenExpiry = Instant.MIN; - public ServiceManagerTokenProvider( - ServiceManagerCredentials credentials, HttpClient httpClient) { + public ServiceManagerTokenProvider(ServiceManagerCredentials credentials, HttpClient httpClient) { this.credentials = credentials; this.httpClient = httpClient; } 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 9122f649..a2e2ffa6 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 @@ -146,7 +146,8 @@ class SingleTenantOperations { @BeforeEach void setup() { mockOsClient = mock(OSClient.class); - handler = new OSSAttachmentsServiceHandler(new SharedOSClientProvider(mockOsClient), false, null); + handler = + new OSSAttachmentsServiceHandler(new SharedOSClientProvider(mockOsClient), false, null); } @Test @@ -225,7 +226,8 @@ class ExceptionHandling { @BeforeEach void setup() { mockOsClient = mock(OSClient.class); - handler = new OSSAttachmentsServiceHandler(new SharedOSClientProvider(mockOsClient), false, null); + handler = + new OSSAttachmentsServiceHandler(new SharedOSClientProvider(mockOsClient), false, null); } @Test @@ -334,7 +336,9 @@ class MultitenancyTests { @BeforeEach void setup() { mockOsClient = mock(OSClient.class); - handler = new OSSAttachmentsServiceHandler(new SharedOSClientProvider(mockOsClient), true, "shared"); + handler = + new OSSAttachmentsServiceHandler( + new SharedOSClientProvider(mockOsClient), true, "shared"); } @Test diff --git a/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/multitenancy/ObjectStoreLifecycleHandlerTest.java b/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/multitenancy/ObjectStoreLifecycleHandlerTest.java index 0c6e2f7f..1c6f336d 100644 --- a/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/multitenancy/ObjectStoreLifecycleHandlerTest.java +++ b/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/multitenancy/ObjectStoreLifecycleHandlerTest.java @@ -122,9 +122,7 @@ void testUnsubscribeHandlesNoBindingGracefully() { void testUnsubscribeContinuesOnDeleteBindingFailure() { when(smClient.getBinding("t1")) .thenReturn(Optional.of(new ServiceManagerBindingResult("bind-1", "inst-1", AWS_CREDS))); - doThrow(new ServiceManagerException("delete failed")) - .when(smClient) - .deleteBinding("bind-1"); + doThrow(new ServiceManagerException("delete failed")).when(smClient).deleteBinding("bind-1"); handler.onTenantUnsubscribe("t1"); // should not throw diff --git a/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/multitenancy/SeparateOSClientProviderTest.java b/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/multitenancy/SeparateOSClientProviderTest.java index 24bf6c70..50297c99 100644 --- a/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/multitenancy/SeparateOSClientProviderTest.java +++ b/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/multitenancy/SeparateOSClientProviderTest.java @@ -35,8 +35,17 @@ void setup() { private void stubBinding(String tenantId) { Map creds = - Map.of("host", "s3.aws.com", "bucket", "b-" + tenantId, "region", "us-east-1", - "access_key_id", "ak", "secret_access_key", "sk"); + Map.of( + "host", + "s3.aws.com", + "bucket", + "b-" + tenantId, + "region", + "us-east-1", + "access_key_id", + "ak", + "secret_access_key", + "sk"); when(smClient.getBinding(tenantId)) .thenReturn(Optional.of(new ServiceManagerBindingResult("bind-1", "inst-1", creds))); } diff --git a/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/multitenancy/sm/ServiceManagerClientTest.java b/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/multitenancy/sm/ServiceManagerClientTest.java index 27fc128e..e1ec91d7 100644 --- a/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/multitenancy/sm/ServiceManagerClientTest.java +++ b/storage-targets/cds-feature-attachments-oss/src/test/java/com/sap/cds/feature/attachments/oss/multitenancy/sm/ServiceManagerClientTest.java @@ -63,8 +63,7 @@ class ReadOperations { @Test void testGetOfferingIdReturnsId() throws Exception { doReturn( - mockResponse( - 200, "{\"items\":[{\"id\":\"offering-123\",\"name\":\"objectstore\"}]}")) + mockResponse(200, "{\"items\":[{\"id\":\"offering-123\",\"name\":\"objectstore\"}]}")) .when(httpClient) .send(any(), any()); @@ -166,7 +165,9 @@ void testCreateInstanceAsyncPollsAndReturnsId() throws Exception { // First call: async 202 with Location header HttpResponse asyncResponse = mockResponse( - 202, "", Map.of("Location", List.of("/v1/service_instances/inst-new/operations/op1"))); + 202, + "", + Map.of("Location", List.of("/v1/service_instances/inst-new/operations/op1"))); // Second call (poll): succeeded HttpResponse pollResponse = mockResponse(200, "{\"id\":\"inst-new\",\"state\":\"succeeded\"}"); @@ -202,8 +203,7 @@ void testCreateBindingReturnsResult() throws Exception { void testCreateBindingThrowsOnError() throws Exception { doReturn(mockResponse(400, "Bad Request")).when(httpClient).send(any(), any()); - assertThrows( - ServiceManagerException.class, () -> client.createBinding("tenant-1", "inst-1")); + assertThrows(ServiceManagerException.class, () -> client.createBinding("tenant-1", "inst-1")); } @Test @@ -231,8 +231,7 @@ void testDeleteInstanceSyncSucceeds() throws Exception { void testDeleteInstanceAsyncSucceeds() throws Exception { HttpResponse asyncResponse = mockResponse(202, "", Map.of("Location", List.of("/v1/service_instances/inst-1/op"))); - HttpResponse pollResponse = - mockResponse(200, "{\"state\":\"succeeded\"}"); + HttpResponse pollResponse = mockResponse(200, "{\"state\":\"succeeded\"}"); doReturn(asyncResponse).doReturn(pollResponse).when(httpClient).send(any(), any()); client.deleteInstance("inst-1"); // should not throw @@ -257,8 +256,7 @@ void testPollTimesOut() throws Exception { .send(any(), any()); assertThrows( - ServiceManagerException.class, - () -> client.pollUntilDone("https://sm.example.com/op/1")); + ServiceManagerException.class, () -> client.pollUntilDone("https://sm.example.com/op/1")); } @Test