This is an automated email from the ASF dual-hosted git repository.

rishabhdaim pushed a commit to branch OAK-12241
in repository https://gitbox.apache.org/repos/asf/jackrabbit-oak.git

commit c0946b9a8f9b9707a941a18ade1fac38dfcedf63
Author: rishabhdaim <[email protected]>
AuthorDate: Fri Jun 19 00:02:06 2026 +0530

    OAK-12241 : updated  S3 tests for oak-blob-cloud to run on S3Mock using 
docker
---
 oak-blob-cloud/pom.xml                             |   5 +
 .../jackrabbit/oak/blob/cloud/s3/S3Constants.java  |   8 +
 .../apache/jackrabbit/oak/blob/cloud/s3/Utils.java |   8 +-
 .../cloud/s3/S3DataRecordAccessProviderIT.java     |   8 +-
 .../cloud/s3/S3DataRecordAccessProviderTest.java   | 171 ++++++++++++++++++++-
 .../oak/blob/cloud/s3/S3DataStoreUtils.java        | 106 +++++++++----
 .../oak/blob/cloud/s3/S3EmulatorSupport.java       | 128 +++++++++++++++
 .../oak/blob/cloud/s3/S3EmulatorSupportTest.java   |  94 +++++++++++
 .../jackrabbit/oak/blob/cloud/s3/S3MockRule.java   | 109 +++++++++++++
 .../jackrabbit/oak/blob/cloud/s3/TestS3Ds.java     |  10 ++
 .../oak/blob/cloud/s3/TestS3DsCacheOff.java        |  23 +++
 .../jackrabbit/oak/blob/cloud/s3/UtilsTest.java    |  70 ++++++++-
 oak-doc/src/site/markdown/osgi_config.md           |   4 +
 13 files changed, 706 insertions(+), 38 deletions(-)

diff --git a/oak-blob-cloud/pom.xml b/oak-blob-cloud/pom.xml
index 9bccf0d064..5b70e6453f 100644
--- a/oak-blob-cloud/pom.xml
+++ b/oak-blob-cloud/pom.xml
@@ -487,6 +487,11 @@
             <artifactId>org.osgi.service.cm</artifactId>
             <scope>test</scope>
         </dependency>
+        <dependency>
+            <groupId>org.testcontainers</groupId>
+            <artifactId>testcontainers</artifactId>
+            <scope>test</scope>
+        </dependency>
     </dependencies>
 
 </project>
diff --git 
a/oak-blob-cloud/src/main/java/org/apache/jackrabbit/oak/blob/cloud/s3/S3Constants.java
 
b/oak-blob-cloud/src/main/java/org/apache/jackrabbit/oak/blob/cloud/s3/S3Constants.java
index 7e70b6f13d..5f7238e3b2 100644
--- 
a/oak-blob-cloud/src/main/java/org/apache/jackrabbit/oak/blob/cloud/s3/S3Constants.java
+++ 
b/oak-blob-cloud/src/main/java/org/apache/jackrabbit/oak/blob/cloud/s3/S3Constants.java
@@ -169,6 +169,14 @@ public final class S3Constants {
      */
     public static final String PRESIGNED_HTTP_DOWNLOAD_URI_VERIFY_EXISTS = 
"presignedHttpDownloadURIVerifyExists";
 
+    /**
+     * Boolean property to enable path-style access for S3 clients ({@code 
pathStyleAccess=true}).
+     * When enabled, requests use {@code http://endpoint/bucket/key} instead of
+     * {@code https://bucket.endpoint/key}. Defaults to {@code false}.
+     * Always enabled automatically when the remote storage mode is GCP.
+     */
+    public static final String PATH_STYLE_ACCESS = "pathStyleAccess";
+
     /**
      * private constructor so that class cannot initialized from outside.
      */
diff --git 
a/oak-blob-cloud/src/main/java/org/apache/jackrabbit/oak/blob/cloud/s3/Utils.java
 
b/oak-blob-cloud/src/main/java/org/apache/jackrabbit/oak/blob/cloud/s3/Utils.java
index 6c479076a1..3d5239adab 100644
--- 
a/oak-blob-cloud/src/main/java/org/apache/jackrabbit/oak/blob/cloud/s3/Utils.java
+++ 
b/oak-blob-cloud/src/main/java/org/apache/jackrabbit/oak/blob/cloud/s3/Utils.java
@@ -170,7 +170,7 @@ public final class Utils {
                 .credentialsProvider(Utils.getAwsCredentials(props))
                 .region(Region.of(Utils.getRegion(props)))
                 .serviceConfiguration(S3Configuration.builder()
-                        .pathStyleAccessEnabled(isGCP)
+                        
.pathStyleAccessEnabled(isPathStyleAccessEnabled(props, isGCP))
                         .chunkedEncodingEnabled(!isGCP)
                         .build())
                 .build();
@@ -584,12 +584,16 @@ public final class Utils {
 
         builder.serviceConfiguration(
                 S3Configuration.builder()
-                        .pathStyleAccessEnabled(isGCP) // enable for GCP
+                        .pathStyleAccessEnabled(isPathStyleAccessEnabled(prop, 
isGCP)) // always on for GCP; opt-in for S3 via pathStyleAccess property
                         .chunkedEncodingEnabled(!isGCP) // Disable for GCP
                         .useArnRegionEnabled(!isGCP)  // Disable for GCP
                         .build());
     }
 
+    private static boolean isPathStyleAccessEnabled(Properties prop, boolean 
isGCP) {
+        return isGCP || 
Boolean.parseBoolean(prop.getProperty(S3Constants.PATH_STYLE_ACCESS, "false"));
+    }
+
     // Helper class to hold common Http config
     private static class HttpClientConfig {
         final int connectionTimeout;
diff --git 
a/oak-blob-cloud/src/test/java/org/apache/jackrabbit/oak/blob/cloud/s3/S3DataRecordAccessProviderIT.java
 
b/oak-blob-cloud/src/test/java/org/apache/jackrabbit/oak/blob/cloud/s3/S3DataRecordAccessProviderIT.java
index fa36a39a29..0958b3d941 100644
--- 
a/oak-blob-cloud/src/test/java/org/apache/jackrabbit/oak/blob/cloud/s3/S3DataRecordAccessProviderIT.java
+++ 
b/oak-blob-cloud/src/test/java/org/apache/jackrabbit/oak/blob/cloud/s3/S3DataRecordAccessProviderIT.java
@@ -20,6 +20,7 @@ package org.apache.jackrabbit.oak.blob.cloud.s3;
 
 import java.io.File;
 import java.io.IOException;
+import java.io.InputStream;
 import java.net.URI;
 
 import javax.net.ssl.HttpsURLConnection;
@@ -80,6 +81,11 @@ public class S3DataRecordAccessProviderIT extends 
AbstractDataRecordAccessProvid
 
     @Override
     protected HttpsURLConnection getHttpsConnection(long length, URI uri) 
throws IOException {
-        return S3DataStoreUtils.getHttpsConnection(length, uri);
+        throw new UnsupportedOperationException("Upload is handled by 
doHttpsUpload; see S3DataStoreUtils.doHttpUpload");
+    }
+
+    @Override
+    protected void doHttpsUpload(InputStream in, long contentLength, URI uri) 
throws IOException {
+        S3DataStoreUtils.doHttpUpload(in, contentLength, uri);
     }
 }
diff --git 
a/oak-blob-cloud/src/test/java/org/apache/jackrabbit/oak/blob/cloud/s3/S3DataRecordAccessProviderTest.java
 
b/oak-blob-cloud/src/test/java/org/apache/jackrabbit/oak/blob/cloud/s3/S3DataRecordAccessProviderTest.java
index cf59f79645..c8f390540d 100644
--- 
a/oak-blob-cloud/src/test/java/org/apache/jackrabbit/oak/blob/cloud/s3/S3DataRecordAccessProviderTest.java
+++ 
b/oak-blob-cloud/src/test/java/org/apache/jackrabbit/oak/blob/cloud/s3/S3DataRecordAccessProviderTest.java
@@ -22,20 +22,29 @@ import static 
org.apache.jackrabbit.oak.blob.cloud.s3.S3DataStoreUtils.getFixtur
 import static 
org.apache.jackrabbit.oak.blob.cloud.s3.S3DataStoreUtils.getS3Config;
 import static 
org.apache.jackrabbit.oak.blob.cloud.s3.S3DataStoreUtils.getS3DataStore;
 import static 
org.apache.jackrabbit.oak.blob.cloud.s3.S3DataStoreUtils.isS3Configured;
+import static 
org.apache.jackrabbit.oak.plugins.blob.datastore.DataStoreUtils.randomStream;
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertTrue;
+import static org.junit.Assume.assumeFalse;
 import static org.junit.Assume.assumeTrue;
 
 import java.io.File;
 import java.io.IOException;
 import java.io.InputStream;
+import java.net.HttpURLConnection;
 import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import java.util.List;
 import java.util.Map;
 import java.util.Objects;
 import java.util.Properties;
 
 import javax.net.ssl.HttpsURLConnection;
 
+import org.apache.jackrabbit.oak.api.blob.BlobDownloadOptions;
+import 
org.apache.jackrabbit.oak.plugins.blob.datastore.directaccess.DataRecordAccessProvider;
+import 
org.apache.jackrabbit.oak.plugins.blob.datastore.directaccess.DataRecordDownloadOptions;
 import org.apache.jackrabbit.oak.spi.blob.data.DataIdentifier;
 import org.apache.jackrabbit.oak.spi.blob.data.DataRecord;
 import org.apache.jackrabbit.oak.spi.blob.data.DataStore;
@@ -131,7 +140,12 @@ public class S3DataRecordAccessProviderTest extends 
AbstractDataRecordAccessProv
 
     @Override
     protected HttpsURLConnection getHttpsConnection(long length, URI uri) 
throws IOException {
-        return S3DataStoreUtils.getHttpsConnection(length, uri);
+        throw new UnsupportedOperationException("S3Mock uses HTTP; upload is 
handled by doHttpsUpload");
+    }
+
+    @Override
+    protected void doHttpsUpload(InputStream in, long contentLength, URI uri) 
throws IOException {
+        S3DataStoreUtils.doHttpUpload(in, contentLength, uri);
     }
 
     @Test
@@ -168,4 +182,159 @@ public class S3DataRecordAccessProviderTest extends 
AbstractDataRecordAccessProv
         upload = ds.initiateDataRecordUpload(uploadSize, -1);
         assertEquals(expectedNumURIs, upload.getUploadURIs().size());
     }
+
+    @Override
+    @Test
+    public void testGetDownloadURIIT() throws DataStoreException, IOException {
+        assumeTrue("SSE-C doesn't support presigned GET URLs", 
!isSSECustomerKeyEncryption());
+        assumeTrue("S3Presigner does not inherit endpointOverride from 
S3Client; presigned URLs point to real AWS, not emulator",
+                !S3EmulatorSupport.isAvailable());
+        DataRecord record = null;
+        DataRecordAccessProvider dataStore = getDataStore();
+        try {
+            InputStream testStream = randomStream(0, 256);
+            record = doSynchronousAddRecord((DataStore) dataStore, testStream);
+            URI uri = dataStore.getDownloadURI(record.getIdentifier(), 
DataRecordDownloadOptions.DEFAULT);
+            HttpURLConnection conn = (HttpURLConnection) 
uri.toURL().openConnection();
+            try {
+                conn.setRequestMethod("GET");
+                assertEquals(200, conn.getResponseCode());
+
+                testStream.reset();
+                try (InputStream responseStream = conn.getInputStream()) {
+                    assertTrue(Arrays.equals(testStream.readAllBytes(), 
responseStream.readAllBytes()));
+                }
+            } finally {
+                conn.disconnect();
+            }
+        } finally {
+            if (null != record) {
+                doDeleteRecord((DataStore) dataStore, record.getIdentifier());
+            }
+        }
+    }
+
+    @Override
+    @Test
+    public void testGetDownloadURIWithCustomHeadersIT() throws 
DataStoreException, IOException {
+        assumeTrue("SSE-C doesn't support presigned GET URLs", 
!isSSECustomerKeyEncryption());
+        assumeTrue("S3Presigner does not inherit endpointOverride from 
S3Client; presigned URLs point to real AWS, not emulator",
+                !S3EmulatorSupport.isAvailable());
+        String umlautFilename = "Umläutfile.png";
+        String umlautFilename_ISO_8859_1 = new String(
+                StandardCharsets.ISO_8859_1.encode(umlautFilename).array(),
+                StandardCharsets.ISO_8859_1
+        );
+        List<String> fileNames = List.of(
+                "image.png",
+                "beautiful landscape.png",
+                "\"filename-with-double-quotes\".png",
+                "filename-with-one\"double-quote.jpg",
+                umlautFilename
+        );
+        List<String> iso_8859_1_fileNames = List.of(
+                "image.png",
+                "beautiful landscape.png",
+                "\\\"filename-with-double-quotes\\\".png",
+                "filename-with-one\\\"double-quote.jpg",
+                umlautFilename_ISO_8859_1
+        );
+        List<String> rfc8187_fileNames = List.of(
+                "image.png",
+                "beautiful%20landscape.png",
+                "%22filename-with-double-quotes%22.png",
+                "filename-with-one%22double-quote.jpg",
+                "Uml%C3%A4utfile.png"
+        );
+
+        DataRecord record = null;
+        DataRecordAccessProvider dataStore = getDataStore();
+        try {
+            InputStream testStream = randomStream(0, 256);
+            record = doSynchronousAddRecord((DataStore) dataStore, testStream);
+            String mimeType = "image/png";
+            String dispositionType = "inline";
+            for (int i = 0; i < fileNames.size(); i++) {
+                String fileName = fileNames.get(i);
+                String iso_8859_1_fileName = iso_8859_1_fileNames.get(i);
+                String encodedFileName = rfc8187_fileNames.get(i);
+                DataRecordDownloadOptions downloadOptions =
+                        DataRecordDownloadOptions.fromBlobDownloadOptions(
+                                new BlobDownloadOptions(mimeType, null, 
fileName, dispositionType)
+                        );
+                URI uri = dataStore.getDownloadURI(record.getIdentifier(), 
downloadOptions);
+
+                HttpURLConnection conn = (HttpURLConnection) 
uri.toURL().openConnection();
+                try {
+                    conn.setRequestMethod("GET");
+                    assertEquals(200, conn.getResponseCode());
+
+                    assertEquals(mimeType, 
conn.getHeaderField("Content-Type"));
+                    assertEquals(
+                            String.format("%s; filename=\"%s\"; 
filename*=UTF-8''%s",
+                                    dispositionType, iso_8859_1_fileName, 
encodedFileName),
+                            conn.getHeaderField("Content-Disposition")
+                    );
+
+                    testStream.reset();
+                    try (InputStream responseStream = conn.getInputStream()) {
+                        assertTrue(Arrays.equals(testStream.readAllBytes(), 
responseStream.readAllBytes()));
+                    }
+                } finally {
+                    conn.disconnect();
+                }
+            }
+        } finally {
+            if (null != record) {
+                doDeleteRecord((DataStore) dataStore, record.getIdentifier());
+            }
+        }
+    }
+
+    @Override
+    @Test
+    public void testSinglePutDirectUploadIT() throws 
DataRecordUploadException, DataStoreException, IOException {
+        assumeTrue("S3Presigner does not inherit endpointOverride from 
S3Client; presigned PUT URLs point to real AWS, not emulator",
+                !S3EmulatorSupport.isAvailable());
+        super.testSinglePutDirectUploadIT();
+    }
+
+    @Override
+    @Test
+    public void testCompleteAlreadyUploadedBinaryReturnsSameBinaryIT() throws 
DataStoreException, DataRecordUploadException, IOException {
+        assumeTrue("S3Presigner does not inherit endpointOverride from 
S3Client; presigned PUT URLs point to real AWS, not emulator",
+                !S3EmulatorSupport.isAvailable());
+        super.testCompleteAlreadyUploadedBinaryReturnsSameBinaryIT();
+    }
+
+    @Override
+    @Test
+    public void testGetExpiredReadURIFailsIT() throws DataStoreException, 
IOException {
+        assumeTrue("SSE-C doesn't support presigned GET URLs", 
!isSSECustomerKeyEncryption());
+        assumeFalse("S3Presigner does not inherit endpointOverride from 
S3Client; presigned URLs point to real AWS, not emulator",
+                S3EmulatorSupport.isAvailable());
+        DataRecord record = null;
+        ConfigurableDataRecordAccessProvider dataStore = getDataStore();
+        try {
+            dataStore.setDirectDownloadURIExpirySeconds(2);
+            record = doSynchronousAddRecord((DataStore) dataStore, 
randomStream(0, 256));
+            URI uri = dataStore.getDownloadURI(record.getIdentifier(), 
DataRecordDownloadOptions.DEFAULT);
+            try {
+                Thread.sleep(5 * 1000);
+            } catch (InterruptedException e) {
+            }
+            HttpURLConnection conn = (HttpURLConnection) 
uri.toURL().openConnection();
+            try {
+                conn.setRequestMethod("GET");
+                assertEquals(403, conn.getResponseCode());
+            } finally {
+                conn.disconnect();
+            }
+        } finally {
+            if (null != record) {
+                doDeleteRecord((DataStore) dataStore, record.getIdentifier());
+            }
+            dataStore.setDirectDownloadURIExpirySeconds(expirySeconds);
+        }
+    }
 }
diff --git 
a/oak-blob-cloud/src/test/java/org/apache/jackrabbit/oak/blob/cloud/s3/S3DataStoreUtils.java
 
b/oak-blob-cloud/src/test/java/org/apache/jackrabbit/oak/blob/cloud/s3/S3DataStoreUtils.java
index 5b695ebe84..4d30c02cc6 100644
--- 
a/oak-blob-cloud/src/test/java/org/apache/jackrabbit/oak/blob/cloud/s3/S3DataStoreUtils.java
+++ 
b/oak-blob-cloud/src/test/java/org/apache/jackrabbit/oak/blob/cloud/s3/S3DataStoreUtils.java
@@ -22,15 +22,16 @@ import java.io.File;
 import java.io.FileInputStream;
 import java.io.IOException;
 import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.HttpURLConnection;
 import java.net.URI;
+import java.net.URLConnection;
 import java.util.Date;
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
 import java.util.Properties;
 
-import javax.net.ssl.HttpsURLConnection;
-
 import org.apache.commons.io.IOUtils;
 import org.apache.jackrabbit.oak.spi.blob.data.DataStore;
 import org.apache.jackrabbit.oak.commons.PropertiesUtil;
@@ -125,9 +126,21 @@ public class S3DataStoreUtils extends DataStoreUtils {
             props = new Properties();
             props.putAll(filtered);
         }
+        // Fall back to emulator when no real credentials are present.
+        if (!hasRealCredentials(props) && S3EmulatorSupport.isAvailable()) {
+            return S3EmulatorSupport.getEmulatorProperties();
+        }
         return props;
     }
 
+    private static boolean hasRealCredentials(Properties props) {
+        String accessKey = props.getProperty(S3Constants.ACCESS_KEY, 
"").trim();
+        String secretKey = props.getProperty(S3Constants.SECRET_KEY, 
"").trim();
+        String region = props.getProperty(S3Constants.S3_REGION, "").trim();
+        String endpoint = props.getProperty(S3Constants.S3_END_POINT, 
"").trim();
+        return !accessKey.isEmpty() && !secretKey.isEmpty() && 
(!region.isEmpty() || !endpoint.isEmpty());
+    }
+
     public static DataStore getS3DataStore(String className, Properties props, 
String homeDir) throws Exception {
         DataStore ds = 
Class.forName(className).asSubclass(DataStore.class).newInstance();
         PropertiesUtil.populate(ds, Utils.asMap(props), false);
@@ -150,38 +163,65 @@ public class S3DataStoreUtils extends DataStoreUtils {
         S3BackendHelper.deleteBucketAndAbortMultipartUploads(bucket, date, 
props);
     }
 
-    protected static HttpsURLConnection getHttpsConnection(long length, URI 
uri) throws IOException {
-        HttpsURLConnection conn = (HttpsURLConnection) 
uri.toURL().openConnection();
-        conn.setDoOutput(true);
-        conn.setRequestMethod("PUT");
-        conn.setRequestProperty("Content-Length", String.valueOf(length));
-
-        final Properties props = getS3Config();
-        DataEncryption encryption = Utils.getDataEncryption(props);
-
-        switch (encryption) {
-            case SSE_S3:
-                conn.setRequestProperty("x-amz-server-side-encryption", 
"AES256");
-                System.out.println("Added SSE-S3 header: AES256");
-                break;
-            case SSE_KMS:
-                conn.setRequestProperty("x-amz-server-side-encryption", 
"aws:kms");
-                System.out.println("Added SSE-KMS header: aws:kms");
-                if 
(Objects.nonNull(props.getProperty(S3Constants.S3_SSE_KMS_KEYID))) {
-                    
conn.setRequestProperty("x-amz-server-side-encryption-aws-kms-key-id", 
props.getProperty(S3Constants.S3_SSE_KMS_KEYID));
-                    System.out.println("Added KMS Key ID: " + 
props.getProperty(S3Constants.S3_SSE_KMS_KEYID));
-                }
-                break;
-            case SSE_C:
-                String customerKey = 
props.getProperty(S3Constants.S3_SSE_C_KEY);
-                String customerKeyMD5 = Utils.calculateMD5(customerKey);
-                
conn.setRequestProperty("x-amz-server-side-encryption-customer-algorithm", 
"AES256");
-                
conn.setRequestProperty("x-amz-server-side-encryption-customer-key", 
customerKey);
-                
conn.setRequestProperty("x-amz-server-side-encryption-customer-key-MD5", 
customerKeyMD5);
-                break;
-            case NONE:
-                break;
+    protected static HttpURLConnection getHttpConnection(long length, URI uri) 
throws IOException {
+        URLConnection raw = uri.toURL().openConnection();
+        if (!(raw instanceof HttpURLConnection)) {
+            throw new IOException("Expected HTTP(S) connection for URI: " + 
uri);
+        }
+        HttpURLConnection conn = (HttpURLConnection) raw;
+        try {
+            conn.setDoOutput(true);
+            conn.setRequestMethod("PUT");
+            conn.setRequestProperty("Content-Length", String.valueOf(length));
+
+            final Properties props = getS3Config();
+            DataEncryption encryption = Utils.getDataEncryption(props);
+
+            switch (encryption) {
+                case SSE_S3:
+                    conn.setRequestProperty("x-amz-server-side-encryption", 
"AES256");
+                    System.out.println("Added SSE-S3 header: AES256");
+                    break;
+                case SSE_KMS:
+                    conn.setRequestProperty("x-amz-server-side-encryption", 
"aws:kms");
+                    System.out.println("Added SSE-KMS header: aws:kms");
+                    if 
(Objects.nonNull(props.getProperty(S3Constants.S3_SSE_KMS_KEYID))) {
+                        
conn.setRequestProperty("x-amz-server-side-encryption-aws-kms-key-id", 
props.getProperty(S3Constants.S3_SSE_KMS_KEYID));
+                        System.out.println("Added KMS Key ID: " + 
props.getProperty(S3Constants.S3_SSE_KMS_KEYID));
+                    }
+                    break;
+                case SSE_C:
+                    String customerKey = 
props.getProperty(S3Constants.S3_SSE_C_KEY);
+                    String customerKeyMD5 = Utils.calculateMD5(customerKey);
+                    
conn.setRequestProperty("x-amz-server-side-encryption-customer-algorithm", 
"AES256");
+                    
conn.setRequestProperty("x-amz-server-side-encryption-customer-key", 
customerKey);
+                    
conn.setRequestProperty("x-amz-server-side-encryption-customer-key-MD5", 
customerKeyMD5);
+                    break;
+                case NONE:
+                    break;
+            }
+        } catch (RuntimeException e) {
+            conn.disconnect();
+            throw e;
         }
         return conn;
     }
+
+    protected static void doHttpUpload(InputStream in, long contentLength, URI 
uri) throws IOException {
+        HttpURLConnection conn = getHttpConnection(contentLength, uri);
+        try {
+            try (OutputStream out = conn.getOutputStream()) {
+                IOUtils.copy(in, out);
+            }
+            int responseCode = conn.getResponseCode();
+            org.junit.Assert.assertTrue(conn.getResponseMessage(), 
responseCode < 400);
+        } finally {
+            InputStream err = conn.getErrorStream();
+            if (err != null) {
+                try { IOUtils.toByteArray(err); } catch (IOException ignored) 
{}
+                IOUtils.closeQuietly(err);
+            }
+            conn.disconnect();
+        }
+    }
 }
diff --git 
a/oak-blob-cloud/src/test/java/org/apache/jackrabbit/oak/blob/cloud/s3/S3EmulatorSupport.java
 
b/oak-blob-cloud/src/test/java/org/apache/jackrabbit/oak/blob/cloud/s3/S3EmulatorSupport.java
new file mode 100644
index 0000000000..d8fc9cdc84
--- /dev/null
+++ 
b/oak-blob-cloud/src/test/java/org/apache/jackrabbit/oak/blob/cloud/s3/S3EmulatorSupport.java
@@ -0,0 +1,128 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.jackrabbit.oak.blob.cloud.s3;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.Properties;
+
+/**
+ * Lazy singleton that starts an {@link S3MockRule} Docker container on first 
use and exposes
+ * S3 emulator properties for use in {@link S3DataStoreUtils#getS3Config()}.
+ *
+ * <p>GCP emulator coverage is intentionally out of scope because S3Mock is 
not compatible
+ * with Oak's GCP client configuration.</p>
+ *
+ * <p>If Docker is unavailable, {@link #isAvailable()} returns {@code false} 
and tests
+ * skip rather than fail.</p>
+ */
+public final class S3EmulatorSupport {
+
+    private static final Logger log = 
LoggerFactory.getLogger(S3EmulatorSupport.class);
+
+    /** System property reserved for selecting emulator mode; only S3 is 
currently supported. */
+    static final String S3_TEST_MODE_PROP = "s3.test.mode";
+
+    /** Dummy access key accepted by S3Mock. */
+    static final String ACCESS_KEY = "foo";
+
+    /** Dummy secret key accepted by S3Mock. */
+    static final String SECRET_KEY = "bar";
+
+    /** Default bucket name used when no test-specific bucket is configured. */
+    static final String DEFAULT_BUCKET = "s3mock-default-test-bucket";
+
+    private static volatile S3MockRule instance;
+    private static volatile boolean startupAttempted = false;
+
+    private S3EmulatorSupport() {}
+
+    /**
+     * Returns {@code true} if the S3Mock container started successfully and 
is available for tests.
+     */
+    public static boolean isAvailable() {
+        if (isUnsupportedMode()) {
+            return false;
+        }
+        ensureStarted();
+        return instance != null;
+    }
+
+    /**
+     * Returns a {@link Properties} instance configured for the S3 emulator.
+     * Returns an empty {@link Properties} if the container is not available.
+     */
+    public static Properties getEmulatorProperties() {
+        if (isUnsupportedMode()) {
+            return new Properties();
+        }
+        ensureStarted();
+        S3MockRule rule = instance;
+        if (rule == null) {
+            return new Properties();
+        }
+        String endpoint = rule.getHttpEndpoint();
+        Properties props = new Properties();
+        props.setProperty(S3Constants.ACCESS_KEY, ACCESS_KEY);
+        props.setProperty(S3Constants.SECRET_KEY, SECRET_KEY);
+        props.setProperty(S3Constants.S3_BUCKET, DEFAULT_BUCKET);
+        props.setProperty(S3Constants.S3_CONN_PROTOCOL, "http");
+        props.setProperty(S3Constants.PATH_STYLE_ACCESS, "true");
+        props.setProperty(S3Constants.S3_ENCRYPTION, 
S3Constants.S3_ENCRYPTION_NONE);
+        props.setProperty(S3Constants.S3_END_POINT, endpoint);
+        props.setProperty(S3Constants.S3_REGION, "us-east-1");
+        props.setProperty(S3Constants.S3_MAX_ERR_RETRY, "3");
+        props.setProperty(S3Constants.S3_CONN_TIMEOUT, "10000");
+        props.setProperty(S3Constants.S3_SOCK_TIMEOUT, "30000");
+        props.setProperty(S3Constants.S3_MAX_CONNS, "10");
+        return props;
+    }
+
+    private static void ensureStarted() {
+        if (isUnsupportedMode() || instance != null) {
+            return;
+        }
+        synchronized (S3EmulatorSupport.class) {
+            if (instance != null || startupAttempted) {
+                return;
+            }
+            startupAttempted = true;
+            S3MockRule rule = new S3MockRule();
+            try {
+                rule.before();
+                instance = rule;
+                Runtime.getRuntime().addShutdownHook(new Thread(() -> {
+                    try {
+                        rule.after();
+                    } catch (Exception ignored) {
+                    }
+                }));
+            } catch (Throwable e) {
+                try {
+                    rule.after();
+                } catch (Exception ignored) {
+                }
+                log.warn("S3Mock container failed to start — emulator tests 
will be skipped", e);
+            }
+        }
+    }
+
+    private static boolean isUnsupportedMode() {
+        return !"S3".equalsIgnoreCase(System.getProperty(S3_TEST_MODE_PROP, 
"S3"));
+    }
+}
diff --git 
a/oak-blob-cloud/src/test/java/org/apache/jackrabbit/oak/blob/cloud/s3/S3EmulatorSupportTest.java
 
b/oak-blob-cloud/src/test/java/org/apache/jackrabbit/oak/blob/cloud/s3/S3EmulatorSupportTest.java
new file mode 100644
index 0000000000..0e58146efc
--- /dev/null
+++ 
b/oak-blob-cloud/src/test/java/org/apache/jackrabbit/oak/blob/cloud/s3/S3EmulatorSupportTest.java
@@ -0,0 +1,94 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.jackrabbit.oak.blob.cloud.s3;
+
+import org.junit.After;
+import org.junit.Assert;
+import org.junit.Assume;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.util.Properties;
+
+/**
+ * Tests for {@link S3EmulatorSupport}.
+ */
+public class S3EmulatorSupportTest {
+
+    private String previousMode;
+
+    @Before
+    public void setUp() {
+        previousMode = System.getProperty(S3EmulatorSupport.S3_TEST_MODE_PROP);
+    }
+
+    @After
+    public void tearDown() {
+        if (previousMode == null) {
+            System.clearProperty(S3EmulatorSupport.S3_TEST_MODE_PROP);
+        } else {
+            System.setProperty(S3EmulatorSupport.S3_TEST_MODE_PROP, 
previousMode);
+        }
+    }
+
+    @Test
+    public void isAvailableReturnsFalseForUnsupportedGcpMode() {
+        System.setProperty(S3EmulatorSupport.S3_TEST_MODE_PROP, "GCP");
+        Assert.assertFalse(S3EmulatorSupport.isAvailable());
+    }
+
+    @Test
+    public void isAvailableReturnsTrueInS3ModeWhenDockerAvailable() {
+        System.clearProperty(S3EmulatorSupport.S3_TEST_MODE_PROP);
+        Assume.assumeTrue("Docker is not available", 
S3EmulatorSupport.isAvailable());
+        Assert.assertTrue(S3EmulatorSupport.isAvailable());
+    }
+
+    @Test
+    public void getEmulatorPropertiesReturnsEmptyForUnsupportedGcpMode() {
+        System.setProperty(S3EmulatorSupport.S3_TEST_MODE_PROP, "GCP");
+        Assert.assertTrue(S3EmulatorSupport.getEmulatorProperties().isEmpty());
+    }
+
+    @Test
+    public void getEmulatorPropertiesReturnsEmptyWhenUnavailable() {
+        // When Docker is not available, isAvailable() is false and 
getEmulatorProperties()
+        // must return empty properties rather than throwing.
+        Assume.assumeFalse("Docker is available", 
S3EmulatorSupport.isAvailable());
+        Properties props = S3EmulatorSupport.getEmulatorProperties();
+        Assert.assertTrue("Expected empty properties when emulator is 
unavailable", props.isEmpty());
+    }
+
+    @Test
+    public void getEmulatorPropertiesInS3ModeContainsRequiredKeys() {
+        System.clearProperty(S3EmulatorSupport.S3_TEST_MODE_PROP);
+        Assume.assumeTrue("Docker is not available", 
S3EmulatorSupport.isAvailable());
+
+        Properties props = S3EmulatorSupport.getEmulatorProperties();
+        Assert.assertNotNull(props.getProperty(S3Constants.ACCESS_KEY));
+        Assert.assertNotNull(props.getProperty(S3Constants.SECRET_KEY));
+        Assert.assertNotNull(props.getProperty(S3Constants.S3_END_POINT));
+        Assert.assertNotNull(props.getProperty(S3Constants.S3_REGION));
+        Assert.assertNotNull(props.getProperty(S3Constants.S3_BUCKET));
+        Assert.assertEquals("true", 
props.getProperty(S3Constants.PATH_STYLE_ACCESS));
+        Assert.assertNotNull(props.getProperty(S3Constants.S3_MAX_ERR_RETRY));
+        Assert.assertNotNull(props.getProperty(S3Constants.S3_CONN_TIMEOUT));
+        Assert.assertEquals("http", 
props.getProperty(S3Constants.S3_CONN_PROTOCOL));
+        Assert.assertEquals(S3Constants.S3_ENCRYPTION_NONE, 
props.getProperty(S3Constants.S3_ENCRYPTION));
+        Assert.assertNull("S3 mode must not set MODE", 
props.get(S3Constants.MODE));
+    }
+}
diff --git 
a/oak-blob-cloud/src/test/java/org/apache/jackrabbit/oak/blob/cloud/s3/S3MockRule.java
 
b/oak-blob-cloud/src/test/java/org/apache/jackrabbit/oak/blob/cloud/s3/S3MockRule.java
new file mode 100644
index 0000000000..d1878c7749
--- /dev/null
+++ 
b/oak-blob-cloud/src/test/java/org/apache/jackrabbit/oak/blob/cloud/s3/S3MockRule.java
@@ -0,0 +1,109 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.jackrabbit.oak.blob.cloud.s3;
+
+import org.junit.Assume;
+import org.junit.rules.ExternalResource;
+import org.junit.runner.Description;
+import org.junit.runners.model.MultipleFailureException;
+import org.junit.runners.model.Statement;
+import org.testcontainers.containers.GenericContainer;
+import org.testcontainers.utility.DockerImageName;
+
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicReference;
+
+/**
+ * JUnit rule that starts an Adobe S3Mock Docker container for emulator-based 
S3 integration tests.
+ * Mirrors the pattern used by {@code AzuriteDockerRule} in 
oak-blob-cloud-azure.
+ *
+ * <p>On Docker failure, the rule calls {@link Assume#assumeNoException} so 
tests are skipped
+ * rather than failed, preserving the same behaviour as when no credentials 
are configured.</p>
+ */
+public class S3MockRule extends ExternalResource {
+
+    private static final DockerImageName DOCKER_IMAGE = 
DockerImageName.parse("adobe/s3mock:5.0.0");
+    static final int HTTP_PORT = 9090;
+    private final AtomicReference<Exception> startupException = new 
AtomicReference<>();
+
+    private GenericContainer<?> container;
+
+    @Override
+    protected void before() throws Throwable {
+        startupException.set(null);
+        container = new GenericContainer<>(DOCKER_IMAGE)
+                .withExposedPorts(HTTP_PORT)
+                .withStartupTimeout(Duration.ofSeconds(60));
+        try {
+            container.start();
+        } catch (Exception e) {
+            startupException.set(e);
+            throw e;
+        }
+    }
+
+    @Override
+    protected void after() {
+        if (container != null) {
+            container.stop();
+        }
+    }
+
+    @Override
+    public Statement apply(Statement base, Description description) {
+        return new Statement() {
+            @Override
+            public void evaluate() throws Throwable {
+                try {
+                    before();
+                } catch (Exception e) {
+                    Assume.assumeNoException(e);
+                    throw e;
+                }
+                List<Throwable> errors = new ArrayList<>();
+                try {
+                    base.evaluate();
+                } catch (Throwable t) {
+                    errors.add(t);
+                } finally {
+                    try {
+                        after();
+                    } catch (Throwable t) {
+                        errors.add(t);
+                    }
+                }
+                MultipleFailureException.assertEmpty(errors);
+            }
+        };
+    }
+
+    /**
+     * Returns the mapped HTTP port on the host for the running S3Mock 
container.
+     */
+    public int getHttpPort() {
+        return container.getMappedPort(HTTP_PORT);
+    }
+
+    /**
+     * Returns the HTTP endpoint URL for the running S3Mock container, e.g. 
{@code http://127.0.0.1:PORT}.
+     */
+    public String getHttpEndpoint() {
+        return "http://127.0.0.1:"; + getHttpPort();
+    }
+}
diff --git 
a/oak-blob-cloud/src/test/java/org/apache/jackrabbit/oak/blob/cloud/s3/TestS3Ds.java
 
b/oak-blob-cloud/src/test/java/org/apache/jackrabbit/oak/blob/cloud/s3/TestS3Ds.java
index 00e79a73c2..51d55ec985 100644
--- 
a/oak-blob-cloud/src/test/java/org/apache/jackrabbit/oak/blob/cloud/s3/TestS3Ds.java
+++ 
b/oak-blob-cloud/src/test/java/org/apache/jackrabbit/oak/blob/cloud/s3/TestS3Ds.java
@@ -315,6 +315,16 @@ public class TestS3Ds extends AbstractDataStoreTest {
     public void testDeleteAllOlderThan() {
     }
 
+    // deleteRecord removes the object from S3 but leaves the local file cache 
intact.
+    // getRecordIfStored then returns the cached copy, causing a spurious 
failure.
+    // The cache-off subclass (TestS3DsCacheOff) re-enables this test where it 
is valid.
+    @Override
+    public void testDeleteRecord() {
+        Assume.assumeFalse("S3 local cache masks deletions; not verifiable 
against the emulator",
+                S3EmulatorSupport.isAvailable());
+        super.testDeleteRecord();
+    }
+
     // helper methods
 
     private PutObjectResponse httpPut(@Nullable DataRecordUpload 
uploadContext, InputStream inputstream, long length) {
diff --git 
a/oak-blob-cloud/src/test/java/org/apache/jackrabbit/oak/blob/cloud/s3/TestS3DsCacheOff.java
 
b/oak-blob-cloud/src/test/java/org/apache/jackrabbit/oak/blob/cloud/s3/TestS3DsCacheOff.java
index fb62f1ee8e..eea9f30cfd 100644
--- 
a/oak-blob-cloud/src/test/java/org/apache/jackrabbit/oak/blob/cloud/s3/TestS3DsCacheOff.java
+++ 
b/oak-blob-cloud/src/test/java/org/apache/jackrabbit/oak/blob/cloud/s3/TestS3DsCacheOff.java
@@ -17,7 +17,9 @@
 package org.apache.jackrabbit.oak.blob.cloud.s3;
 
 import org.apache.jackrabbit.oak.spi.blob.data.CachingDataStore;
+import org.junit.Assume;
 import org.junit.Before;
+import org.junit.Test;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -40,4 +42,25 @@ public class TestS3DsCacheOff extends TestS3Ds {
         props.setProperty("cacheSize", "0");
         super.setUp();
     }
+
+    // Re-enable: with cache off, deleteRecord is immediately visible via S3 
so the
+    // assertion that getRecordIfStored returns null after deletion is valid 
here.
+    @Override
+    @Test
+    public void testDeleteRecord() {
+        try {
+            doDeleteRecordTest();
+        } catch (Exception e) {
+            org.junit.Assert.fail(e.getMessage());
+        }
+    }
+
+    // S3Backend updates duplicate records via CopyObject (copy-to-self). 
S3Mock does not
+    // support that operation, so this test cannot run against the emulator 
with cache off.
+    @Override
+    public void testAddDuplicateRecord() {
+        Assume.assumeFalse("S3Mock does not support CopyObject used by 
S3Backend for duplicate record updates",
+                S3EmulatorSupport.isAvailable());
+        super.testAddDuplicateRecord();
+    }
 }
diff --git 
a/oak-blob-cloud/src/test/java/org/apache/jackrabbit/oak/blob/cloud/s3/UtilsTest.java
 
b/oak-blob-cloud/src/test/java/org/apache/jackrabbit/oak/blob/cloud/s3/UtilsTest.java
index 580c111e3c..fc4b999464 100644
--- 
a/oak-blob-cloud/src/test/java/org/apache/jackrabbit/oak/blob/cloud/s3/UtilsTest.java
+++ 
b/oak-blob-cloud/src/test/java/org/apache/jackrabbit/oak/blob/cloud/s3/UtilsTest.java
@@ -18,8 +18,13 @@
 package org.apache.jackrabbit.oak.blob.cloud.s3;
 
 import org.junit.Assert;
+import org.junit.Assume;
 import org.junit.Test;
 
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
 import java.net.URI;
 import java.util.Properties;
 
@@ -201,4 +206,67 @@ public class UtilsTest {
         Assert.assertEquals(S3Backend.RemoteStorageMode.GCP, 
props.get(S3Constants.MODE));
     }
 
-}
\ No newline at end of file
+    @Test
+    public void testSetRemoteStorageModeDefaultsPreSetGCPToS3() {
+        Properties props = new Properties();
+        props.setProperty("s3EndPoint", "http://127.0.0.1:9090";);
+        props.put(S3Constants.MODE, S3Backend.RemoteStorageMode.GCP);
+        Utils.setRemoteStorageMode(props);
+        Assert.assertEquals(S3Backend.RemoteStorageMode.S3, 
props.get(S3Constants.MODE));
+    }
+
+    @Test
+    public void testSetRemoteStorageModeDefaultsBlankModeToS3() {
+        Properties props = new Properties();
+        props.setProperty("s3EndPoint", "https://s3.amazonaws.com";);
+        props.setProperty(S3Constants.MODE, "");
+        Utils.setRemoteStorageMode(props);
+        Assert.assertEquals(S3Backend.RemoteStorageMode.S3, 
props.get(S3Constants.MODE));
+    }
+
+    @Test
+    public void testPathStyleAccessConstantHasExpectedStringValue() {
+        Assert.assertEquals("pathStyleAccess", S3Constants.PATH_STYLE_ACCESS);
+    }
+
+    @Test
+    public void testPathStyleAccessDefaultsToFalseWhenPropertyAbsent() {
+        Properties props = new Properties();
+        
Assert.assertFalse(Boolean.parseBoolean(props.getProperty(S3Constants.PATH_STYLE_ACCESS,
 "false")));
+    }
+
+    @Test
+    public void testPathStyleAccessTrueWhenPropertySetToTrue() {
+        Properties props = new Properties();
+        props.setProperty(S3Constants.PATH_STYLE_ACCESS, "true");
+        
Assert.assertTrue(Boolean.parseBoolean(props.getProperty(S3Constants.PATH_STYLE_ACCESS,
 "false")));
+    }
+
+    @Test
+    public void 
isS3ConfiguredReturnsFalseForPartialCredentialsWithNoRegionOrEndpoint() throws 
IOException {
+        // accessKey + secretKey without region or endpoint must not count as 
real credentials
+        File tmp = File.createTempFile("s3test-partial-creds", ".properties");
+        String previousConfig = System.getProperty("s3.config");
+        try {
+            Properties partial = new Properties();
+            partial.setProperty(S3Constants.ACCESS_KEY, "somekey");
+            partial.setProperty(S3Constants.SECRET_KEY, "somesecret");
+            try (OutputStream out = new FileOutputStream(tmp)) {
+                partial.store(out, null);
+            }
+            System.setProperty("s3.config", tmp.getAbsolutePath());
+            // Skip when emulator is available — getS3Config() would fall back 
to it, making isS3Configured() true
+            Assume.assumeFalse("Emulator available — would override partial 
creds", S3EmulatorSupport.isAvailable());
+            Assert.assertFalse("Partial credentials (no region/endpoint) must 
not be treated as real",
+                    S3DataStoreUtils.isS3Configured());
+        } finally {
+            if (previousConfig == null) {
+                System.clearProperty("s3.config");
+            } else {
+                System.setProperty("s3.config", previousConfig);
+            }
+            tmp.delete();
+        }
+    }
+
+}
diff --git a/oak-doc/src/site/markdown/osgi_config.md 
b/oak-doc/src/site/markdown/osgi_config.md
index 67c471500a..0b8ae52598 100644
--- a/oak-doc/src/site/markdown/osgi_config.md
+++ b/oak-doc/src/site/markdown/osgi_config.md
@@ -367,6 +367,10 @@ s3Region
 s3EndPoint
 : S3 rest API endpoint. Can help reduce latency of redirection from standard 
endpoint if a different region configured.
 
+pathStyleAccess
+: Default - `false`
+: Set to `true` to use path-style S3 requests (`http://endpoint/bucket/key`) 
instead of virtual-host-style requests. This is useful for S3-compatible 
endpoints that do not support bucket names in the host. Note: always enabled 
automatically when the remote storage mode is GCP; the property has no effect 
in that case.
+
 connectionTimeout
 : S3 connection timeout. See [AWS S3 
documentation](https://docs.aws.amazon.com/sdk-for-java/v1/developer-guide/section-client-configuration.html).
 


Reply via email to