diff --git a/.junie/guidelines.md b/.junie/guidelines.md
index 31d3c4d79..7d0d5129a 100644
--- a/.junie/guidelines.md
+++ b/.junie/guidelines.md
@@ -30,6 +30,28 @@ Troubleshooting
- SSL errors: trust self‑signed cert or switch to HTTP.
- Docker errors: ensure Docker is running and you have permissions.
+## Junie Operations Playbook (Critical)
+To ensure tests execute successfully in this environment, follow these strict rules:
+
+- Default test scope: server module only. Do NOT run full project builds by default.
+- Use the test tool, not shell, to run tests:
+ - Preferred: run_test on specific test files, e.g., "server/src/test/kotlin/com/adobe/testing/s3mock/store/ObjectStoreTest.kt".
+ - One test by name: use run_test with full path and the test method name parameter.
+ - Note: Directory-wide runs via run_test may not be supported in this environment. If you need to run all server tests, use Maven with: ./mvnw -pl server -DskipDocker test.
+- Avoid integration tests unless explicitly requested and Docker availability is confirmed. If requested, run via Maven lifecycle only.
+- If a build is required, prefer fast builds:
+ - Use ./mvnw -pl server -am -DskipDocker clean test or rely on run_test which compiles as needed.
+ - Only run ./mvnw clean install (full) when the user explicitly asks for a full build or cross-module changes demand it.
+- Never run mvnw verify without confirming Docker is available; if not available, add -DskipDocker.
+- Java 17+ required; if build fails due to JDK, report and stop, do not retry with different commands.
+- Decision tree:
+ 1) Need to validate changes in server module? -> run_test on one or more specific test files (fast path). If you truly need all server tests, use: ./mvnw -pl server -DskipDocker test.
+ 2) Need a specific server test? -> run_test on that file.
+ 3) Need ITs and Docker is confirmed? -> mvnw -pl integration-tests -am verify; otherwise skip.
+ 4) Need a build artifact quickly? -> mvnw clean install -DskipDocker.
+
+Note: Always summarize which scope you ran and why.
+
—
## Build and Configuration Instructions
@@ -95,7 +117,7 @@ The main test base class for integration tests is `S3TestBase` which provides ut
The server module contains several types of tests:
-1. **Controller Tests**: Use `@SpringBootTest` with `WebEnvironment.RANDOM_PORT` and `TestRestTemplate` to test HTTP endpoints. These tests mock the service layer using `@MockBean`.
+1. **Controller Tests**: Use `@SpringBootTest` with `WebEnvironment.RANDOM_PORT` and `TestRestTemplate` to test HTTP endpoints. These tests mock the service layer using `@MockitoBean`.
2. **Store Tests**: Use `@SpringBootTest` with `WebEnvironment.NONE` to test the data storage layer. These tests often use `@Autowired` to inject the component under test.
@@ -213,7 +235,7 @@ The server module uses different testing approaches depending on what's being te
1. **Controller Tests**:
- Extend `BaseControllerTest` to inherit XML serialization setup
- Use `@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)`
- - Use `@MockBean` to mock service dependencies
+ - Use `@MockitoBean` to mock service dependencies
- Inject `TestRestTemplate` to make HTTP requests to the controller
Example controller test:
@@ -221,12 +243,12 @@ Example controller test:
```kotlin
// BucketControllerTest.kt
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
-@MockBean(classes = [BucketService::class, ObjectService::class, MultipartService::class])
+@MockitoBean(classes = [BucketService::class, ObjectService::class, MultipartService::class])
internal class BucketControllerTest : BaseControllerTest() {
@Autowired
private lateinit var restTemplate: TestRestTemplate
- @MockBean
+ @MockitoBean
private lateinit var bucketService: BucketService
@Test
@@ -254,7 +276,7 @@ Example store test:
```kotlin
// ObjectStoreTest.kt
@SpringBootTest(classes = [StoreConfiguration::class], webEnvironment = SpringBootTest.WebEnvironment.NONE)
-@MockBean(classes = [KmsKeyStore::class, BucketStore::class])
+@MockitoBean(classes = [KmsKeyStore::class, BucketStore::class])
internal class ObjectStoreTest : StoreTestBase() {
@Autowired
private lateinit var objectStore: ObjectStore
@@ -352,9 +374,13 @@ docker run -p 9090:9090 -p 9191:9191 -e debug=true -t adobe/s3mock
### Recommended Development Workflow
1. Make changes to the code
-2. Run unit tests frequently to verify basic functionality
- - Unit tests should be run very frequently during development
- - No task can be declared as done without running a full Maven build successfully
-3. Run integration tests to verify end-to-end functionality
-4. Build the Docker image to verify packaging
-5. Test with your application to verify real-world usage
+2. Validate changes with server module tests first (fast path)
+ - Use the run_test tool on "server/src/test" or on a specific test file/method.
+ - Prefer this over invoking Maven directly; run_test compiles as needed.
+3. Only run a full Maven build when explicitly requested or when cross-module changes demand it
+ - If building in this environment, prefer fast builds: ./mvnw -pl server -am -DskipDocker clean test
+ - Do not run mvnw verify unless Docker is confirmed; otherwise add -DskipDocker
+4. Run integration tests only when Docker availability is confirmed and when explicitly requested
+ - Execute via Maven lifecycle: ./mvnw -pl integration-tests -am verify (or add -DskipDocker to skip ITs)
+5. Optionally build the Docker image to verify packaging when needed
+6. Test with your application to verify real-world usage
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f3ba7a58a..c6e280c3b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,7 +8,8 @@ Whenever a 3rd party library is updated, S3Mock will update it's MINOR version.
* [PLANNED - 5.x - RELEASE TBD](#planned---5x---release-tbd)
* [Planned changes](#planned-changes)
* [CURRENT - 4.x - THIS VERSION IS UNDER ACTIVE DEVELOPMENT](#current---4x---this-version-is-under-active-development)
- * [4.8.0 - PLANNED](#480---planned)
+ * [4.9.0 - PLANNED](#490---planned)
+ * [4.8.0](#480)
* [4.7.0](#470)
* [4.6.0](#460)
* [4.5.1](#451)
@@ -160,7 +161,7 @@ Version 4.x is JDK17 LTS bytecode compatible, with Docker and JUnit / direct Jav
* Version updates (build dependencies)
* TBD
-## 4.8.0 - PLANNED
+## 4.8.0
Version 4.x is JDK17 LTS bytecode compatible, with Docker and JUnit / direct Java integration.
* Features and fixes
@@ -171,14 +172,17 @@ Version 4.x is JDK17 LTS bytecode compatible, with Docker and JUnit / direct Jav
* Force convergence on the newest available transitive dependency versions.
* Optimize file storage for large objects by using buffered streams.
* Version updates (deliverable dependencies)
- *
+ * Bump spring-boot.version from 3.5.4 to 3.5.5
+ * Bump aws-v2.version from 2.32.7 to 2.32.23
* Bump org.apache.commons:commons-compress from 1.27.1 to 1.28.0
* Version updates (build dependencies)
* Bump kotlin.version from 2.2.0 to 2.2.10
+ * Bump aws.sdk.kotlin:s3-jvm from 1.4.125 to 1.5.19
* Bump digital.pragmatech.testing:spring-test-profiler from 0.0.5 to 0.0.11
* Bump com.puppycrawl.tools:checkstyle from 10.26.1 to 11.0.0
- * Bump github/codeql-action from 3.29.4 to 3.29.9
+ * Bump github/codeql-action from 3.29.4 to 3.29.11
* Bump actions/checkout from 4.2.2 to 5.0.0
+ * Bump actions/setup-java from 4.7.1 to 5.0.0
## 4.7.0
Version 4.x is JDK17 LTS bytecode compatible, with Docker and JUnit / direct Java integration.
@@ -197,9 +201,11 @@ Version 4.x is JDK17 LTS bytecode compatible, with Docker and JUnit / direct Jav
* Version updates (build dependencies)
* Bump aws.sdk.kotlin:s3-jvm from 1.4.109 to 1.4.125
* Bump org.apache.maven.plugins:maven-enforcer-plugin from 3.6.0 to 3.6.1
+ * Bump org.apache.maven.plugins:maven-javadoc-plugin from 3.11.2 to 3.11.3
* Bump org.mockito.kotlin:mockito-kotlin from 5.4.0 to 6.0.0
* Bump step-security/harden-runner from 2.12.2 to 2.13.0
- * Bump github/codeql-action from 3.29.2 to 3.29.4
+ * Bump github/codeql-action from 3.29.2 to 3.29.10
+ * Bump actions/dependency-review-action from 4.7.1 to 4.7.2
## 4.6.0
Version 4.x is JDK17 LTS bytecode compatible, with Docker and JUnit / direct Java integration.
diff --git a/README.md b/README.md
index a42b6238a..5d40695da 100755
--- a/README.md
+++ b/README.md
@@ -837,3 +837,6 @@ This project is licensed under the Apache V2 License. See [LICENSE](LICENSE) for
## Powered by
[](https://jb.gg/OpenSourceSupport)
+
+## Star History
+[](https://www.star-history.com/#adobe/S3Mock&Date)
diff --git a/docs/tasks.md b/docs/tasks.md
index 3e2948ce4..e14c2a35c 100644
--- a/docs/tasks.md
+++ b/docs/tasks.md
@@ -17,7 +17,75 @@ This document contains a list of potential improvements for the S3Mock project.
## Code Quality Improvements
-11. [ ] Increase unit test coverage for service and store layers
+11. [ ] Increase unit test coverage for controller, service and store layers
+
+### Task 11 – Test Coverage Plan (Components and Actions)
+
+Scope and components to cover:
+- Controllers (HTTP): BucketController, ObjectController, MultipartController
+- Services (business logic): BucketService, ObjectService, MultipartService, Kms* services
+- Stores (persistence): BucketStore, ObjectStore, MultipartStore, KmsKeyStore
+- XML/DTOs and mappers: request/response XML models, serialization utils
+- Utilities: digest, ETag, headers, Range requests, SSE
+- Configuration: StoreConfiguration, controller advice/error mapping
+
+Priorities (short-to-long horizon):
+1) High-value happy-path and error-path coverage for controllers with mocked services (fast feedback).
+2) Store layer correctness with Spring Boot WebEnvironment.NONE tests (file IO, metadata persistence, edge cases).
+3) Service layer behavior with mocked stores (parameter validation, branching, SSE/KMS interactions).
+4) XML serialization/deserialization fidelity for commonly used operations.
+5) Regression tests for known corner cases (range requests, conditional headers, multipart completion ordering, KMS key validation).
+
+Concrete test additions (incremental):
+- Controllers
+ - BucketController
+ - listBuckets returns empty and non-empty results; XML schema shape
+ - createBucket duplicate name -> proper S3 error code
+ - deleteBucket non-empty -> proper error
+ - ObjectController
+ - putObject with/without Content-MD5; mismatched MD5 -> error
+ - getObject with Range header (single range) -> 206 + Content-Range
+ - getObject nonexistent -> 404 S3-style error
+ - headObject verifies metadata and headers (ETag, Content-Length)
+ - MultipartController
+ - initiateMultipartUpload returns uploadId
+ - uploadPart with invalid partNumber -> error mapping
+ - completeMultipartUpload out-of-order parts -> consistent ETag behavior
+- Services
+ - ObjectService.storeObject validates metadata, handles SSE headers routing to KMS
+ - BucketService.deleteBucket checks emptiness guard
+- Stores
+ - ObjectStore
+ - storeS3ObjectMetadata and getS3ObjectMetadata roundtrip
+ - list with prefix/delimiter, max-keys, continuation
+ - delete removes metadata and data file
+ - BucketStore
+ - create, list, delete, exist checks
+ - MultipartStore
+ - init, addPart, complete, abort state transitions
+- XML/DTOs
+ - Serialize/deserialize ListAllMyBucketsResult, CompleteMultipartUploadResult
+
+Suggested file locations (server module):
+- Controllers: server/src/test/kotlin/com/adobe/testing/s3mock/itlike/controller/*Test.kt (extending BaseControllerTest)
+- Services: server/src/test/kotlin/com/adobe/testing/s3mock/service/*Test.kt
+- Stores: server/src/test/kotlin/com/adobe/testing/s3mock/store/*Test.kt (extend StoreTestBase)
+- DTOs: server/src/test/kotlin/com/adobe/testing/s3mock/xml/*Test.kt
+
+Execution (fast path per repo guidelines):
+- One test class: ./mvnw -pl server test -Dtest=ObjectStoreTest
+- One method: ./mvnw -pl server test -Dtest=ObjectStoreTest#testStoreAndGetObject
+- Or via tool: run_test server/src/test/kotlin/com/adobe/testing/s3mock/store/ObjectStoreTest.kt
+
+Acceptance targets for Task 11 completion:
+- +10–15% line coverage increase in server module, focusing on controllers and stores
+- At least one new test per component category listed above
+- Error-path assertions include correct HTTP status and S3 error codes/messages
+
+Notes:
+- Avoid ITs unless Docker available; prefer WebEnvironment.RANDOM_PORT controller tests with mocked services.
+- Use provided test bases: BaseControllerTest, StoreTestBase, ServiceTestBase.
+- Reuse existing sample files: server/src/test/resources/sampleFile.txt, sampleFile_large.txt, sampleKMSFile.txt.
12. [ ] Refactor synchronization mechanisms in store classes to improve concurrency handling
13. [ ] Implement more comprehensive input validation for S3 API parameters
14. [ ] Add more detailed logging throughout the application for better debugging
@@ -36,7 +104,7 @@ This document contains a list of potential improvements for the S3Mock project.
24. [ ] Improve multipart upload performance
25. [x] Reduce memory usage when handling large files
26. [ ] Optimize XML serialization/deserialization
-27. [ ] Implement more efficient storage of object metadata
+27. [x] Keep object metadata storage as plain text (JSON) for inspectability (decided against more efficient/binary storage)
28. [ ] Add support for conditional requests to reduce unnecessary data transfer
29. [ ] Optimize concurrent access patterns
30. [ ] Implement more efficient bucket and object locking mechanisms
diff --git a/integration-tests/pom.xml b/integration-tests/pom.xml
index ea82d61ea..944afe78f 100644
--- a/integration-tests/pom.xml
+++ b/integration-tests/pom.xml
@@ -58,16 +58,10 @@
awaitility
test
-
- org.jetbrains.kotlin
- kotlin-stdlib-jdk8
- test
-
org.jetbrains.kotlin
kotlin-stdlib
- ${kotlin.version}
test
diff --git a/pom.xml b/pom.xml
index 583923685..a7955a8e9 100644
--- a/pom.xml
+++ b/pom.xml
@@ -184,7 +184,7 @@
org.jetbrains.kotlin
- kotlin-stdlib-jdk8
+ kotlin-stdlib
${kotlin.version}
diff --git a/server/pom.xml b/server/pom.xml
index a6abf2199..929cd56c1 100644
--- a/server/pom.xml
+++ b/server/pom.xml
@@ -51,6 +51,11 @@
com.fasterxml.jackson.datatype
jackson-datatype-jdk8
+
+ com.fasterxml.jackson.module
+ jackson-module-kotlin
+ test
+
software.amazon.awssdk
regions
@@ -83,6 +88,10 @@
org.jspecify
jspecify
+
+ org.jetbrains.kotlin
+ kotlin-stdlib
+
org.apache.httpcomponents
diff --git a/server/src/main/java/com/adobe/testing/s3mock/MultipartController.java b/server/src/main/java/com/adobe/testing/s3mock/MultipartController.java
index a8f2e1275..1e1bcc103 100644
--- a/server/src/main/java/com/adobe/testing/s3mock/MultipartController.java
+++ b/server/src/main/java/com/adobe/testing/s3mock/MultipartController.java
@@ -140,16 +140,16 @@ public ResponseEntity listMultipartUploads(
@RequestParam(name = UPLOAD_ID_MARKER, required = false) String uploadIdMarker) {
bucketService.verifyBucketExists(bucketName);
- return ResponseEntity.ok(multipartService.listMultipartUploads(
- bucketName,
- delimiter,
- encodingType,
- keyMarker,
- maxUploads,
- prefix,
- uploadIdMarker
- )
+ var result = multipartService.listMultipartUploads(
+ bucketName,
+ delimiter,
+ encodingType,
+ keyMarker,
+ maxUploads,
+ prefix,
+ uploadIdMarker
);
+ return ResponseEntity.ok(result);
}
//================================================================================================
@@ -198,14 +198,15 @@ public ResponseEntity listParts(
bucketService.verifyBucketExists(bucketName);
multipartService.verifyMultipartUploadExists(bucketName, uploadId);
+ var result = multipartService.getMultipartUploadParts(
+ bucketName,
+ key.key(),
+ maxParts,
+ partNumberMarker,
+ uploadId
+ );
return ResponseEntity
- .ok(multipartService.getMultipartUploadParts(
- bucketName,
- key.key(),
- maxParts,
- partNumberMarker,
- uploadId)
- );
+ .ok(result);
}
diff --git a/server/src/main/java/com/adobe/testing/s3mock/dto/RegionSerializer.java b/server/src/main/java/com/adobe/testing/s3mock/dto/RegionSerializer.java
index ce43c8cbe..781d21bfb 100644
--- a/server/src/main/java/com/adobe/testing/s3mock/dto/RegionSerializer.java
+++ b/server/src/main/java/com/adobe/testing/s3mock/dto/RegionSerializer.java
@@ -33,7 +33,8 @@ public void serialize(Region value, JsonGenerator gen, SerializerProvider serial
//API doc says to return "null" for the us-east-1 region.
if ("us-east-1".equals(regionString)) {
gen.writeString("null");
+ } else {
+ gen.writeString(regionString);
}
- gen.writeString(regionString);
}
}
diff --git a/server/src/test/kotlin/com/adobe/testing/s3mock/BaseControllerTest.kt b/server/src/test/kotlin/com/adobe/testing/s3mock/BaseControllerTest.kt
index 60f12f3cc..ca88427c6 100644
--- a/server/src/test/kotlin/com/adobe/testing/s3mock/BaseControllerTest.kt
+++ b/server/src/test/kotlin/com/adobe/testing/s3mock/BaseControllerTest.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2017-2024 Adobe.
+ * Copyright 2017-2025 Adobe.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -20,23 +20,20 @@ import com.fasterxml.jackson.annotation.JsonInclude
import com.fasterxml.jackson.dataformat.xml.XmlMapper
import com.fasterxml.jackson.dataformat.xml.deser.FromXmlParser
import com.fasterxml.jackson.dataformat.xml.ser.ToXmlGenerator
-import org.junit.jupiter.api.BeforeAll
internal abstract class BaseControllerTest {
companion object {
- @JvmStatic
- protected lateinit var MAPPER: XmlMapper
+ val MAPPER: XmlMapper = XmlMapper.builder()
+ .findAndAddModules()
+ .enable(ToXmlGenerator.Feature.WRITE_XML_DECLARATION)
+ .enable(ToXmlGenerator.Feature.AUTO_DETECT_XSI_TYPE)
+ .enable(FromXmlParser.Feature.AUTO_DETECT_XSI_TYPE)
+ .build()
- @JvmStatic
- @BeforeAll
- fun setup() {
- MAPPER = XmlMapper().enable(ToXmlGenerator.Feature.WRITE_XML_DECLARATION).apply {
- this as XmlMapper
- this.setSerializationInclusion(JsonInclude.Include.NON_EMPTY)
- this.enable(ToXmlGenerator.Feature.AUTO_DETECT_XSI_TYPE)
- this.enable(FromXmlParser.Feature.AUTO_DETECT_XSI_TYPE)
- this.factory.xmlOutputFactory.setProperty(WstxOutputProperties.P_USE_DOUBLE_QUOTES_IN_XML_DECL, true)
- } as XmlMapper
+ init {
+ MAPPER.setSerializationInclusion(JsonInclude.Include.NON_EMPTY)
+ MAPPER.factory.xmlOutputFactory
+ .setProperty(WstxOutputProperties.P_USE_DOUBLE_QUOTES_IN_XML_DECL, true)
}
}
}
diff --git a/server/src/test/kotlin/com/adobe/testing/s3mock/BucketControllerTest.kt b/server/src/test/kotlin/com/adobe/testing/s3mock/BucketControllerTest.kt
index 01ac183f9..16e28873f 100644
--- a/server/src/test/kotlin/com/adobe/testing/s3mock/BucketControllerTest.kt
+++ b/server/src/test/kotlin/com/adobe/testing/s3mock/BucketControllerTest.kt
@@ -32,6 +32,8 @@ import com.adobe.testing.s3mock.dto.LifecycleRuleFilter
import com.adobe.testing.s3mock.dto.ListAllMyBucketsResult
import com.adobe.testing.s3mock.dto.ListBucketResult
import com.adobe.testing.s3mock.dto.ListBucketResultV2
+import com.adobe.testing.s3mock.dto.ListVersionsResult
+import com.adobe.testing.s3mock.dto.VersioningConfiguration
import com.adobe.testing.s3mock.dto.LocationConstraint
import com.adobe.testing.s3mock.dto.LocationInfo
import com.adobe.testing.s3mock.dto.LocationType.AVAILABILITY_ZONE
@@ -60,28 +62,27 @@ import org.mockito.ArgumentMatchers
import org.mockito.ArgumentMatchers.any
import org.mockito.kotlin.doThrow
import org.mockito.kotlin.verify
+import org.mockito.kotlin.eq
import org.mockito.kotlin.whenever
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
-import org.springframework.boot.test.mock.mockito.MockBean
import org.springframework.boot.test.web.client.TestRestTemplate
import org.springframework.http.HttpEntity
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpMethod
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
+import org.springframework.test.context.bean.override.mockito.MockitoBean
import org.springframework.web.util.UriComponentsBuilder
import java.net.URI
import java.nio.file.Paths
import java.time.Instant
-@MockBean(
- classes = [KmsKeyStore::class, ObjectService::class, MultipartService::class, ObjectController::class, MultipartController::class]
-)
+@MockitoBean(types = [KmsKeyStore::class, ObjectService::class, MultipartService::class, ObjectController::class, MultipartController::class])
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = ["com.adobe.testing.s3mock.region=us-east-1"])
internal class BucketControllerTest : BaseControllerTest() {
- @MockBean
+ @MockitoBean
private lateinit var bucketService: BucketService
@Autowired
@@ -756,6 +757,161 @@ internal class BucketControllerTest : BaseControllerTest() {
assertThat(response.body).isEqualTo(MAPPER.writeValueAsString(configuration))
}
+ @Test
+ @Throws(Exception::class)
+ fun testDeleteBucketLifecycleConfiguration_NoContent() {
+ givenBucket()
+
+ val headers = HttpHeaders().apply {
+ this.accept = listOf(MediaType.APPLICATION_XML)
+ this.contentType = MediaType.APPLICATION_XML
+ }
+ val uri = UriComponentsBuilder
+ .fromUriString("/test-bucket")
+ .queryParam(AwsHttpParameters.LIFECYCLE, "ignored")
+ .build()
+ .toString()
+
+ val response = restTemplate.exchange(
+ uri,
+ HttpMethod.DELETE,
+ HttpEntity(headers),
+ String::class.java
+ )
+ assertThat(response.statusCode).isEqualTo(HttpStatus.NO_CONTENT)
+ verify(bucketService).deleteBucketLifecycleConfiguration(TEST_BUCKET_NAME)
+ }
+
+ @Test
+ @Throws(Exception::class)
+ fun testGetBucketLocation_Ok() {
+ givenBucket()
+
+ val headers = HttpHeaders().apply {
+ this.accept = listOf(MediaType.APPLICATION_XML)
+ this.contentType = MediaType.APPLICATION_XML
+ }
+ val uri = UriComponentsBuilder
+ .fromUriString("/test-bucket")
+ .queryParam(AwsHttpParameters.LOCATION, "ignored")
+ .build()
+ .toString()
+
+ val response = restTemplate.exchange(
+ uri,
+ HttpMethod.GET,
+ HttpEntity(headers),
+ String::class.java
+ )
+ assertThat(response.statusCode).isEqualTo(HttpStatus.OK)
+ assertThat(response.body).isEqualTo(MAPPER.writeValueAsString(LocationConstraint("us-west-2")))
+ }
+
+ @Test
+ @Throws(Exception::class)
+ fun testGetBucketVersioningConfiguration_Ok() {
+ givenBucket()
+ val expected = VersioningConfiguration(VersioningConfiguration.MFADelete.DISABLED, VersioningConfiguration.Status.ENABLED, null)
+
+ whenever(bucketService.getVersioningConfiguration(TEST_BUCKET_NAME)).thenReturn(expected)
+
+ val headers = HttpHeaders().apply {
+ this.accept = listOf(MediaType.APPLICATION_XML)
+ this.contentType = MediaType.APPLICATION_XML
+ }
+ val uri = UriComponentsBuilder
+ .fromUriString("/test-bucket")
+ .queryParam(AwsHttpParameters.VERSIONING, "ignored")
+ .build()
+ .toString()
+ val response = restTemplate.exchange(
+ uri,
+ HttpMethod.GET,
+ HttpEntity(headers),
+ String::class.java
+ )
+ assertThat(response.statusCode).isEqualTo(HttpStatus.OK)
+ assertThat(response.body).isEqualTo(MAPPER.writeValueAsString(expected))
+ }
+
+ @Test
+ @Throws(Exception::class)
+ fun testPutBucketVersioningConfiguration_Ok() {
+ givenBucket()
+ val configuration = VersioningConfiguration(VersioningConfiguration.MFADelete.DISABLED, VersioningConfiguration.Status.SUSPENDED, null)
+
+ val headers = HttpHeaders().apply {
+ this.accept = listOf(MediaType.APPLICATION_XML)
+ this.contentType = MediaType.APPLICATION_XML
+ }
+ val uri = UriComponentsBuilder
+ .fromUriString("/test-bucket")
+ .queryParam(AwsHttpParameters.VERSIONING, "ignored")
+ .build()
+ .toString()
+ val response = restTemplate.exchange(
+ uri,
+ HttpMethod.PUT,
+ HttpEntity(MAPPER.writeValueAsString(configuration), headers),
+ String::class.java
+ )
+ assertThat(response.statusCode).isEqualTo(HttpStatus.OK)
+ verify(bucketService).setVersioningConfiguration(TEST_BUCKET_NAME, configuration)
+ }
+
+ @Test
+ @Throws(Exception::class)
+ fun testListObjectVersions_Ok() {
+ givenBucket()
+
+ val expected = ListVersionsResult(
+ emptyList(),
+ emptyList(),
+ "",
+ "",
+ false,
+ "",
+ MAX_KEYS_DEFAULT,
+ TEST_BUCKET_NAME,
+ "",
+ "",
+ "",
+ emptyList(),
+ ""
+ )
+
+ whenever(
+ bucketService.listVersions(
+ eq(TEST_BUCKET_NAME),
+ any(),
+ any(),
+ any(),
+ eq(MAX_KEYS_DEFAULT),
+ any(),
+ any()
+ )
+ ).thenReturn(expected)
+
+ val headers = HttpHeaders().apply {
+ this.accept = listOf(MediaType.APPLICATION_XML)
+ this.contentType = MediaType.APPLICATION_XML
+ }
+ val uri = UriComponentsBuilder
+ .fromUriString("/test-bucket")
+ .queryParam(AwsHttpParameters.VERSIONS, "ignored")
+ .build()
+ .toString()
+
+ val response = restTemplate.exchange(
+ uri,
+ HttpMethod.GET,
+ HttpEntity(headers),
+ String::class.java
+ )
+ assertThat(response.statusCode).isEqualTo(HttpStatus.OK)
+ assertThat(response.body).isEqualTo(MAPPER.writeValueAsString(expected))
+ }
+
private fun givenBuckets(count: Int = 0,
prefix: String? = null,
@@ -840,7 +996,7 @@ internal class BucketControllerTest : BaseControllerTest() {
private val TEST_OWNER = Owner("s3-mock-file-store", "123")
private const val TEST_BUCKET_NAME = "test-bucket"
private val CREATION_DATE = Instant.now().toString()
- private const val BUCKET_REGION = "us-east-1"
+ private const val BUCKET_REGION = "us-west-2"
private val BUCKET_PATH = Paths.get("/tmp/foo/1")
private const val MAX_BUCKETS_DEFAULT = 1000
private const val MAX_KEYS_DEFAULT = 1000
diff --git a/server/src/test/kotlin/com/adobe/testing/s3mock/ContextPathObjectStoreControllerTest.kt b/server/src/test/kotlin/com/adobe/testing/s3mock/ContextPathObjectStoreControllerTest.kt
index a9dbe0116..89833a3be 100644
--- a/server/src/test/kotlin/com/adobe/testing/s3mock/ContextPathObjectStoreControllerTest.kt
+++ b/server/src/test/kotlin/com/adobe/testing/s3mock/ContextPathObjectStoreControllerTest.kt
@@ -29,23 +29,23 @@ import org.junit.jupiter.api.Test
import org.mockito.kotlin.whenever
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
-import org.springframework.boot.test.mock.mockito.MockBean
import org.springframework.boot.test.web.client.TestRestTemplate
import org.springframework.http.HttpEntity
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpMethod
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
+import org.springframework.test.context.bean.override.mockito.MockitoBean
import java.nio.file.Paths
import java.time.Instant
-@MockBean(classes = [KmsKeyStore::class, ObjectService::class, MultipartService::class, MultipartStore::class])
+@MockitoBean(types = [KmsKeyStore::class, ObjectService::class, MultipartService::class, MultipartStore::class])
@SpringBootTest(
properties = ["com.adobe.testing.s3mock.contextPath=s3-mock"],
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT
)
internal class ContextPathObjectStoreControllerTest : BaseControllerTest() {
- @MockBean
+ @MockitoBean
private lateinit var bucketService: BucketService
@Autowired
diff --git a/server/src/test/kotlin/com/adobe/testing/s3mock/FaviconControllerTest.kt b/server/src/test/kotlin/com/adobe/testing/s3mock/FaviconControllerTest.kt
index 6add64ae1..e8728a9fe 100644
--- a/server/src/test/kotlin/com/adobe/testing/s3mock/FaviconControllerTest.kt
+++ b/server/src/test/kotlin/com/adobe/testing/s3mock/FaviconControllerTest.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2017-2024 Adobe.
+ * Copyright 2017-2025 Adobe.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -20,15 +20,15 @@ import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
-import org.springframework.boot.test.mock.mockito.MockBean
import org.springframework.boot.test.web.client.TestRestTemplate
import org.springframework.http.HttpEntity
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpMethod
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
+import org.springframework.test.context.bean.override.mockito.MockitoBean
-@MockBean(classes = [KmsKeyStore::class, ObjectController::class, BucketController::class, MultipartController::class])
+@MockitoBean(types = [KmsKeyStore::class, ObjectController::class, BucketController::class, MultipartController::class])
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
internal class FaviconControllerTest : BaseControllerTest() {
@Autowired
diff --git a/server/src/test/kotlin/com/adobe/testing/s3mock/MultipartControllerTest.kt b/server/src/test/kotlin/com/adobe/testing/s3mock/MultipartControllerTest.kt
index b56b7015e..ae21a6522 100644
--- a/server/src/test/kotlin/com/adobe/testing/s3mock/MultipartControllerTest.kt
+++ b/server/src/test/kotlin/com/adobe/testing/s3mock/MultipartControllerTest.kt
@@ -16,50 +16,67 @@
package com.adobe.testing.s3mock
import com.adobe.testing.s3mock.dto.Bucket
+import com.adobe.testing.s3mock.dto.ChecksumAlgorithm
+import com.adobe.testing.s3mock.dto.ChecksumType
import com.adobe.testing.s3mock.dto.CompleteMultipartUpload
+import com.adobe.testing.s3mock.dto.CompleteMultipartUploadResult
import com.adobe.testing.s3mock.dto.CompletedPart
+import com.adobe.testing.s3mock.dto.CopyPartResult
import com.adobe.testing.s3mock.dto.ErrorResponse
+import com.adobe.testing.s3mock.dto.InitiateMultipartUploadResult
+import com.adobe.testing.s3mock.dto.ListMultipartUploadsResult
+import com.adobe.testing.s3mock.dto.ListPartsResult
+import com.adobe.testing.s3mock.dto.MultipartUpload
+import com.adobe.testing.s3mock.dto.Owner
import com.adobe.testing.s3mock.dto.Part
+import com.adobe.testing.s3mock.dto.StorageClass
+import com.adobe.testing.s3mock.dto.Tag
+import com.adobe.testing.s3mock.dto.VersioningConfiguration
import com.adobe.testing.s3mock.service.BucketService
import com.adobe.testing.s3mock.service.MultipartService
import com.adobe.testing.s3mock.service.ObjectService
+import com.adobe.testing.s3mock.store.BucketMetadata
import com.adobe.testing.s3mock.store.KmsKeyStore
+import com.adobe.testing.s3mock.store.MultipartUploadInfo
+import com.adobe.testing.s3mock.store.S3ObjectMetadata
+import org.apache.commons.lang3.tuple.Pair
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import org.mockito.ArgumentMatchers.anyList
import org.mockito.ArgumentMatchers.anyString
import org.mockito.ArgumentMatchers.eq
+import org.mockito.kotlin.any
+import org.mockito.kotlin.anyOrNull
import org.mockito.kotlin.doThrow
import org.mockito.kotlin.whenever
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
-import org.springframework.boot.test.mock.mockito.MockBean
-import org.springframework.boot.test.mock.mockito.MockBeans
import org.springframework.boot.test.web.client.TestRestTemplate
import org.springframework.http.HttpEntity
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpMethod
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
+import org.springframework.test.context.bean.override.mockito.MockitoBean
+import org.springframework.util.MultiValueMap
import org.springframework.web.util.UriComponentsBuilder
import java.nio.file.Paths
import java.time.Instant
import java.util.Date
import java.util.UUID
-@MockBeans(
- MockBean(
- classes = [KmsKeyStore::class, ObjectService::class, ObjectController::class, BucketController::class]
- )
-)
+@MockitoBean(types = [KmsKeyStore::class, ObjectController::class, BucketController::class])
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
internal class MultipartControllerTest : BaseControllerTest() {
- @MockBean
+ @MockitoBean
private lateinit var bucketService: BucketService
- @MockBean
+ @MockitoBean
private lateinit var multipartService: MultipartService
+ @MockitoBean
+ private lateinit var objectService: ObjectService
+
@Autowired
private lateinit var restTemplate: TestRestTemplate
@@ -282,26 +299,1565 @@ internal class MultipartControllerTest : BaseControllerTest() {
assertThat(response.body).isEqualTo(MAPPER.writeValueAsString(from(S3Exception.INVALID_PART_ORDER)))
}
- private fun createPart(partNumber: Int, size: Long): Part {
- return Part(partNumber, "someEtag$partNumber", Date(), size)
+ @Test
+ fun testCompleteMultipart_Ok_EncryptionHeadersEchoed() {
+ val bucketMeta = bucketMetadata(versioningEnabled = false)
+ whenever(bucketService.verifyBucketExists(TEST_BUCKET_NAME)).thenReturn(bucketMeta)
+
+ val key = "enc/key.txt"
+ val uploadId = UUID.randomUUID()
+
+ // parts
+ val uploadRequest = CompleteMultipartUpload(ArrayList())
+ uploadRequest.addPart(CompletedPart(null, null, null, null, null, "etag1", 1))
+ uploadRequest.addPart(CompletedPart(null, null, null, null, null, "etag2", 2))
+
+ // object exists and matches
+ val s3meta = s3ObjectMetadata(key, UUID.randomUUID().toString())
+ whenever(objectService.getObject(TEST_BUCKET_NAME, key, null)).thenReturn(s3meta)
+
+ val headers = HttpHeaders().apply {
+ this.accept = listOf(MediaType.APPLICATION_XML)
+ this.contentType = MediaType.APPLICATION_XML
+ }
+
+ // create result with encryption headers to be echoed
+ val mpUpload = MultipartUpload(null, null, Date(), Owner.DEFAULT_OWNER, key, Owner.DEFAULT_OWNER, StorageClass.STANDARD, uploadId.toString())
+ val info = MultipartUploadInfo(
+ mpUpload,
+ "application/octet-stream",
+ emptyMap(),
+ emptyMap(),
+ mapOf("x-amz-server-side-encryption" to "AES256"),
+ TEST_BUCKET_NAME,
+ StorageClass.STANDARD,
+ emptyList(),
+ null,
+ ChecksumType.FULL_OBJECT,
+ null
+ )
+ val result = CompleteMultipartUploadResult.from(
+ "http://localhost/${TEST_BUCKET_NAME}/$key",
+ TEST_BUCKET_NAME,
+ key,
+ "etag-complete",
+ info,
+ null,
+ ChecksumType.FULL_OBJECT,
+ null,
+ null
+ )
+
+ whenever(
+ multipartService.completeMultipartUpload(
+ eq(TEST_BUCKET_NAME),
+ eq(key),
+ eq(uploadId),
+ any(),
+ anyOrNull(),
+ any(),
+ anyOrNull(),
+ anyOrNull()
+ )
+ ).thenReturn(result)
+
+ val uri = UriComponentsBuilder
+ .fromUriString("/${TEST_BUCKET_NAME}/$key")
+ .queryParam("uploadId", uploadId)
+ .build()
+ .toString()
+
+ val response = restTemplate.exchange(
+ uri,
+ HttpMethod.POST,
+ HttpEntity(MAPPER.writeValueAsString(uploadRequest), headers),
+ String::class.java
+ )
+
+ assertThat(response.statusCode).isEqualTo(HttpStatus.OK)
+ assertThat(response.headers.getFirst("x-amz-server-side-encryption")).isEqualTo("AES256")
+ assertThat(response.body).isEqualTo(MAPPER.writeValueAsString(result))
}
- private fun givenBucket() {
- whenever(bucketService.getBucket(TEST_BUCKET_NAME)).thenReturn(TEST_BUCKET)
- whenever(bucketService.doesBucketExist(TEST_BUCKET_NAME)).thenReturn(true)
+ @Test
+ fun testCompleteMultipart_Ok_VersionIdHeaderWhenVersioned() {
+ val bucketMeta = bucketMetadata(versioningEnabled = true)
+ whenever(bucketService.verifyBucketExists(TEST_BUCKET_NAME)).thenReturn(bucketMeta)
+
+ val key = "ver/key.txt"
+ val uploadId = UUID.randomUUID()
+
+ val uploadRequest = CompleteMultipartUpload(ArrayList())
+ uploadRequest.addPart(CompletedPart(null, null, null, null, null, "etag1", 1))
+
+ val s3meta = s3ObjectMetadata(key, UUID.randomUUID().toString())
+ whenever(objectService.getObject(TEST_BUCKET_NAME, key, null)).thenReturn(s3meta)
+
+ val headers = HttpHeaders().apply {
+ this.accept = listOf(MediaType.APPLICATION_XML)
+ this.contentType = MediaType.APPLICATION_XML
+ }
+
+ val mpUpload = MultipartUpload(null, null, Date(), Owner.DEFAULT_OWNER, key, Owner.DEFAULT_OWNER, StorageClass.STANDARD, uploadId.toString())
+ val info = MultipartUploadInfo(
+ mpUpload,
+ "application/octet-stream",
+ emptyMap(),
+ emptyMap(),
+ emptyMap(),
+ TEST_BUCKET_NAME,
+ StorageClass.STANDARD,
+ emptyList(),
+ null,
+ ChecksumType.FULL_OBJECT,
+ null
+ )
+ val result = CompleteMultipartUploadResult.from(
+ "http://localhost/${TEST_BUCKET_NAME}/$key",
+ TEST_BUCKET_NAME,
+ key,
+ "etag-complete",
+ info,
+ null,
+ ChecksumType.FULL_OBJECT,
+ null,
+ "v1"
+ )
+
+ whenever(
+ multipartService.completeMultipartUpload(
+ eq(TEST_BUCKET_NAME), eq(key), eq(uploadId), any(), anyOrNull(), any(), anyOrNull(), anyOrNull()
+ )
+ ).thenReturn(result)
+
+ val uri = UriComponentsBuilder
+ .fromUriString("/${TEST_BUCKET_NAME}/$key")
+ .queryParam("uploadId", uploadId)
+ .build()
+ .toString()
+
+ val response = restTemplate.exchange(
+ uri,
+ HttpMethod.POST,
+ HttpEntity(MAPPER.writeValueAsString(uploadRequest), headers),
+ String::class.java
+ )
+
+ assertThat(response.statusCode).isEqualTo(HttpStatus.OK)
+ assertThat(response.headers.getFirst("x-amz-version-id")).isEqualTo("v1")
+ assertThat(response.body).isEqualTo(MAPPER.writeValueAsString(result))
}
- private fun from(e: S3Exception): ErrorResponse {
- return ErrorResponse(
- e.code,
- e.message,
+ @Test
+ fun testCompleteMultipart_Ok_NoVersionHeaderWhenNotVersioned() {
+ val bucketMeta = bucketMetadata(versioningEnabled = false)
+ whenever(bucketService.verifyBucketExists(TEST_BUCKET_NAME)).thenReturn(bucketMeta)
+
+ val key = "nover/key.txt"
+ val uploadId = UUID.randomUUID()
+
+ val uploadRequest = CompleteMultipartUpload(ArrayList())
+ uploadRequest.addPart(CompletedPart(null, null, null, null, null, "etag1", 1))
+
+ val s3meta = s3ObjectMetadata(key, UUID.randomUUID().toString())
+ whenever(objectService.getObject(TEST_BUCKET_NAME, key, null)).thenReturn(s3meta)
+
+ val headers = HttpHeaders().apply {
+ this.accept = listOf(MediaType.APPLICATION_XML)
+ this.contentType = MediaType.APPLICATION_XML
+ }
+
+ val mpUpload = MultipartUpload(null, null, Date(), Owner.DEFAULT_OWNER, key, Owner.DEFAULT_OWNER, StorageClass.STANDARD, uploadId.toString())
+ val info = MultipartUploadInfo(
+ mpUpload,
+ "application/octet-stream",
+ emptyMap(),
+ emptyMap(),
+ emptyMap(),
+ TEST_BUCKET_NAME,
+ StorageClass.STANDARD,
+ emptyList(),
null,
+ ChecksumType.FULL_OBJECT,
null
)
+ val result = CompleteMultipartUploadResult.from(
+ "http://localhost/${TEST_BUCKET_NAME}/$key",
+ TEST_BUCKET_NAME,
+ key,
+ "etag-complete",
+ info,
+ null,
+ ChecksumType.FULL_OBJECT,
+ null,
+ "v1"
+ )
+
+ whenever(
+ multipartService.completeMultipartUpload(
+ eq(TEST_BUCKET_NAME), eq(key), eq(uploadId), any(), anyOrNull(), any(), anyOrNull(), anyOrNull()
+ )
+ ).thenReturn(result)
+
+ val uri = UriComponentsBuilder
+ .fromUriString("/${TEST_BUCKET_NAME}/$key")
+ .queryParam("uploadId", uploadId)
+ .build()
+ .toString()
+
+ val response = restTemplate.exchange(
+ uri,
+ HttpMethod.POST,
+ HttpEntity(MAPPER.writeValueAsString(uploadRequest), headers),
+ String::class.java
+ )
+
+ assertThat(response.statusCode).isEqualTo(HttpStatus.OK)
+ assertThat(response.headers.getFirst("x-amz-version-id")).isNull()
+ assertThat(response.body).isEqualTo(MAPPER.writeValueAsString(result))
}
- companion object {
- private const val TEST_BUCKET_NAME = "test-bucket"
- private val TEST_BUCKET = Bucket(TEST_BUCKET_NAME, "us-east-1", Instant.now().toString(), Paths.get("/tmp/foo/1"))
+ @Test
+ fun testCompleteMultipart_PreconditionFailed() {
+ val bucketMeta = bucketMetadata(versioningEnabled = false)
+ whenever(bucketService.verifyBucketExists(TEST_BUCKET_NAME)).thenReturn(bucketMeta)
+
+ val key = "pre/key.txt"
+ val uploadId = UUID.randomUUID()
+
+ val uploadRequest = CompleteMultipartUpload(ArrayList())
+ uploadRequest.addPart(CompletedPart(null, null, null, null, null, "etag1", 1))
+
+ val s3meta = s3ObjectMetadata(key, UUID.randomUUID().toString())
+ whenever(objectService.getObject(TEST_BUCKET_NAME, key, null)).thenReturn(s3meta)
+
+ // Simulate precondition failed
+ doThrow(S3Exception.PRECONDITION_FAILED)
+ .whenever(objectService)
+ .verifyObjectMatching(anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), eq(s3meta))
+
+ val headers = HttpHeaders().apply {
+ this.accept = listOf(MediaType.APPLICATION_XML)
+ this.contentType = MediaType.APPLICATION_XML
+ add("If-Match", "non-matching-etag")
+ }
+
+ val uri = UriComponentsBuilder
+ .fromUriString("/${TEST_BUCKET_NAME}/$key")
+ .queryParam("uploadId", uploadId)
+ .build()
+ .toString()
+
+ val response = restTemplate.exchange(
+ uri,
+ HttpMethod.POST,
+ HttpEntity(MAPPER.writeValueAsString(uploadRequest), headers),
+ String::class.java
+ )
+
+ assertThat(response.statusCode).isEqualTo(HttpStatus.PRECONDITION_FAILED)
+ assertThat(response.body).isEqualTo(MAPPER.writeValueAsString(from(S3Exception.PRECONDITION_FAILED)))
+ }
+
+ @Test
+ fun testCompleteMultipart_NoSuchBucket() {
+ doThrow(S3Exception.NO_SUCH_BUCKET)
+ .whenever(bucketService)
+ .verifyBucketExists(TEST_BUCKET_NAME)
+
+ val key = "missing-bucket/key.txt"
+ val uploadId = UUID.randomUUID()
+
+ val uploadRequest = CompleteMultipartUpload(ArrayList())
+ uploadRequest.addPart(CompletedPart(null, null, null, null, null, "etag1", 1))
+
+ val headers = HttpHeaders().apply {
+ this.accept = listOf(MediaType.APPLICATION_XML)
+ this.contentType = MediaType.APPLICATION_XML
+ }
+
+ val uri = UriComponentsBuilder
+ .fromUriString("/${TEST_BUCKET_NAME}/$key")
+ .queryParam("uploadId", uploadId)
+ .build()
+ .toString()
+
+ val response = restTemplate.exchange(
+ uri,
+ HttpMethod.POST,
+ HttpEntity(MAPPER.writeValueAsString(uploadRequest), headers),
+ String::class.java
+ )
+
+ assertThat(response.statusCode).isEqualTo(HttpStatus.NOT_FOUND)
+ assertThat(response.body).isEqualTo(MAPPER.writeValueAsString(from(S3Exception.NO_SUCH_BUCKET)))
+ }
+
+ @Test
+ fun testCompleteMultipart_NoSuchUpload() {
+ val bucketMeta = bucketMetadata(versioningEnabled = false)
+ whenever(bucketService.verifyBucketExists(TEST_BUCKET_NAME)).thenReturn(bucketMeta)
+
+ val key = "no-upload/key.txt"
+ val uploadId = UUID.randomUUID()
+
+ doThrow(S3Exception.NO_SUCH_UPLOAD_MULTIPART)
+ .whenever(multipartService)
+ .verifyMultipartUploadExists(TEST_BUCKET_NAME, uploadId)
+
+ val uploadRequest = CompleteMultipartUpload(ArrayList())
+ uploadRequest.addPart(CompletedPart(null, null, null, null, null, "etag1", 1))
+
+ val headers = HttpHeaders().apply {
+ this.accept = listOf(MediaType.APPLICATION_XML)
+ this.contentType = MediaType.APPLICATION_XML
+ }
+
+ val uri = UriComponentsBuilder
+ .fromUriString("/${TEST_BUCKET_NAME}/$key")
+ .queryParam("uploadId", uploadId)
+ .build()
+ .toString()
+
+ val response = restTemplate.exchange(
+ uri,
+ HttpMethod.POST,
+ HttpEntity(MAPPER.writeValueAsString(uploadRequest), headers),
+ String::class.java
+ )
+
+ assertThat(response.statusCode).isEqualTo(HttpStatus.NOT_FOUND)
+ assertThat(response.body).isEqualTo(MAPPER.writeValueAsString(from(S3Exception.NO_SUCH_UPLOAD_MULTIPART)))
+ }
+
+ @Test
+ fun testListMultipartUploads_Ok() {
+ // Arrange
+ val bucketMeta = bucketMetadata(versioningEnabled = false)
+ whenever(bucketService.verifyBucketExists(TEST_BUCKET_NAME)).thenReturn(bucketMeta)
+ val uploads = listOf(
+ MultipartUpload(
+ null,
+ null,
+ Date(),
+ Owner.DEFAULT_OWNER,
+ "my/key.txt",
+ Owner.DEFAULT_OWNER,
+ StorageClass.STANDARD,
+ "upload-1"
+ )
+ )
+
+ val result = ListMultipartUploadsResult(
+ TEST_BUCKET_NAME,
+ null, // keyMarker
+ null, // delimiter
+ null, // prefix
+ null, // uploadIdMarker
+ 1000,
+ false,
+ null,
+ null,
+ uploads,
+ emptyList(),
+ null
+ )
+ whenever(
+ multipartService.listMultipartUploads(
+ eq(TEST_BUCKET_NAME),
+ anyOrNull(),
+ anyOrNull(),
+ anyOrNull(),
+ eq(1000),
+ anyOrNull(),
+ anyOrNull()
+ )
+ ).thenReturn(result)
+
+ // Act
+ val uri = UriComponentsBuilder
+ .fromUriString("/${TEST_BUCKET_NAME}")
+ .queryParam("uploads", "")
+ .build()
+ .toString()
+ val response = restTemplate.exchange(
+ uri,
+ HttpMethod.GET,
+ HttpEntity.EMPTY,
+ String::class.java
+ )
+
+ // Assert
+ assertThat(response.statusCode).isEqualTo(HttpStatus.OK)
+ assertThat(response.body).isEqualTo(MAPPER.writeValueAsString(result))
+ }
+
+ @Test
+ fun testListMultipartUploads_WithEncodingAndParams_PropagateCorrectly() {
+ val bucketMeta = bucketMetadata(versioningEnabled = false)
+ whenever(bucketService.verifyBucketExists(TEST_BUCKET_NAME)).thenReturn(bucketMeta)
+
+ val delimiter = "/"
+ val encoding = "url"
+ val keyMarker = "key-10"
+ val maxUploads = 5
+ val prefix = "pre"
+ val uploadIdMarker = "u-marker"
+
+ val uploads = listOf(
+ MultipartUpload(null, null, Date(), Owner.DEFAULT_OWNER, "pre/a.txt", Owner.DEFAULT_OWNER, StorageClass.STANDARD, "u-1")
+ )
+ val result = ListMultipartUploadsResult(
+ TEST_BUCKET_NAME,
+ keyMarker,
+ delimiter,
+ prefix,
+ uploadIdMarker,
+ maxUploads,
+ false,
+ null,
+ null,
+ uploads,
+ emptyList(),
+ encoding
+ )
+
+ whenever(
+ multipartService.listMultipartUploads(
+ eq(TEST_BUCKET_NAME),
+ eq(delimiter),
+ eq(encoding),
+ eq(keyMarker),
+ eq(maxUploads),
+ eq(prefix),
+ eq(uploadIdMarker)
+ )
+ ).thenReturn(result)
+
+ val uri = UriComponentsBuilder
+ .fromUriString("/${TEST_BUCKET_NAME}")
+ .queryParam("uploads", "")
+ .queryParam("delimiter", delimiter)
+ .queryParam("encoding-type", encoding)
+ .queryParam("key-marker", keyMarker)
+ .queryParam("max-uploads", maxUploads)
+ .queryParam("prefix", prefix)
+ .queryParam("upload-id-marker", uploadIdMarker)
+ .build()
+ .toString()
+
+ val response = restTemplate.exchange(
+ uri,
+ HttpMethod.GET,
+ HttpEntity.EMPTY,
+ String::class.java
+ )
+
+ assertThat(response.statusCode).isEqualTo(HttpStatus.OK)
+ assertThat(response.body).isEqualTo(MAPPER.writeValueAsString(result))
+ }
+
+ @Test
+ fun testListMultipartUploads_Pagination_ResponseFields() {
+ val bucketMeta = bucketMetadata(versioningEnabled = false)
+ whenever(bucketService.verifyBucketExists(TEST_BUCKET_NAME)).thenReturn(bucketMeta)
+
+ val uploads = listOf(
+ MultipartUpload(null, null, Date(), Owner.DEFAULT_OWNER, "k1", Owner.DEFAULT_OWNER, StorageClass.STANDARD, "u-1")
+ )
+
+ val result = ListMultipartUploadsResult(
+ TEST_BUCKET_NAME,
+ "k0",
+ null,
+ null,
+ "u0",
+ 1,
+ true,
+ "k1",
+ "u1",
+ uploads,
+ emptyList(),
+ null
+ )
+
+ whenever(
+ multipartService.listMultipartUploads(
+ eq(TEST_BUCKET_NAME), anyOrNull(), anyOrNull(), anyOrNull(), eq(1), anyOrNull(), anyOrNull()
+ )
+ ).thenReturn(result)
+
+ val uri = UriComponentsBuilder
+ .fromUriString("/${TEST_BUCKET_NAME}")
+ .queryParam("uploads", "")
+ .queryParam("max-uploads", 1)
+ .build()
+ .toString()
+
+ val response = restTemplate.exchange(
+ uri,
+ HttpMethod.GET,
+ HttpEntity.EMPTY,
+ String::class.java
+ )
+
+ assertThat(response.statusCode).isEqualTo(HttpStatus.OK)
+ assertThat(response.body).isEqualTo(MAPPER.writeValueAsString(result))
+ }
+
+ @Test
+ fun testListMultipartUploads_NoSuchBucket() {
+ // Simulate bucket missing
+ doThrow(S3Exception.NO_SUCH_BUCKET)
+ .whenever(bucketService)
+ .verifyBucketExists(TEST_BUCKET_NAME)
+
+ val uri = UriComponentsBuilder
+ .fromUriString("/${TEST_BUCKET_NAME}")
+ .queryParam("uploads", "")
+ .build()
+ .toString()
+
+ val response = restTemplate.exchange(
+ uri,
+ HttpMethod.GET,
+ HttpEntity.EMPTY,
+ String::class.java
+ )
+
+ assertThat(response.statusCode).isEqualTo(HttpStatus.NOT_FOUND)
+ assertThat(response.body).isEqualTo(MAPPER.writeValueAsString(from(S3Exception.NO_SUCH_BUCKET)))
+ }
+
+ @Test
+ fun testAbortMultipartUpload_NoContent() {
+ val bucketMeta = bucketMetadata(versioningEnabled = false)
+ whenever(bucketService.verifyBucketExists(TEST_BUCKET_NAME)).thenReturn(bucketMeta)
+ val uploadId = UUID.randomUUID()
+
+ val key = "folder/name.txt"
+ val uri = UriComponentsBuilder
+ .fromUriString("/${TEST_BUCKET_NAME}/$key")
+ .queryParam("uploadId", uploadId)
+ .build()
+ .toString()
+
+ val response = restTemplate.exchange(
+ uri,
+ HttpMethod.DELETE,
+ HttpEntity.EMPTY,
+ String::class.java
+ )
+
+ assertThat(response.statusCode).isEqualTo(HttpStatus.NO_CONTENT)
+ }
+
+ @Test
+ fun testAbortMultipartUpload_NoSuchBucket() {
+ // Arrange: bucket does not exist
+ doThrow(S3Exception.NO_SUCH_BUCKET)
+ .whenever(bucketService)
+ .verifyBucketExists(TEST_BUCKET_NAME)
+
+ val key = "some/key.txt"
+ val uploadId = UUID.randomUUID()
+ val uri = UriComponentsBuilder
+ .fromUriString("/${TEST_BUCKET_NAME}/$key")
+ .queryParam("uploadId", uploadId)
+ .build()
+ .toString()
+
+ // Act
+ val response = restTemplate.exchange(
+ uri,
+ HttpMethod.DELETE,
+ HttpEntity.EMPTY,
+ String::class.java
+ )
+
+ // Assert
+ assertThat(response.statusCode).isEqualTo(HttpStatus.NOT_FOUND)
+ assertThat(response.body).isEqualTo(MAPPER.writeValueAsString(from(S3Exception.NO_SUCH_BUCKET)))
+ }
+
+ @Test
+ fun testAbortMultipartUpload_NoSuchUpload() {
+ // Arrange
+ val bucketMeta = bucketMetadata(versioningEnabled = false)
+ whenever(bucketService.verifyBucketExists(TEST_BUCKET_NAME)).thenReturn(bucketMeta)
+
+ val uploadId = UUID.randomUUID()
+ doThrow(S3Exception.NO_SUCH_UPLOAD_MULTIPART)
+ .whenever(multipartService)
+ .verifyMultipartUploadExists(TEST_BUCKET_NAME, uploadId)
+
+ val key = "folder/name.txt"
+ val uri = UriComponentsBuilder
+ .fromUriString("/${TEST_BUCKET_NAME}/$key")
+ .queryParam("uploadId", uploadId)
+ .build()
+ .toString()
+
+ // Act
+ val response = restTemplate.exchange(
+ uri,
+ HttpMethod.DELETE,
+ HttpEntity.EMPTY,
+ String::class.java
+ )
+
+ // Assert
+ assertThat(response.statusCode).isEqualTo(HttpStatus.NOT_FOUND)
+ assertThat(response.body).isEqualTo(MAPPER.writeValueAsString(from(S3Exception.NO_SUCH_UPLOAD_MULTIPART)))
+ }
+
+
+ @Test
+ fun testListParts_Ok() {
+ val bucketMeta = bucketMetadata(versioningEnabled = false)
+ whenever(bucketService.verifyBucketExists(TEST_BUCKET_NAME)).thenReturn(bucketMeta)
+ val uploadId = UUID.randomUUID()
+
+ val parts = listOf(createPart(1, 5L), createPart(2, 6L))
+ val result = ListPartsResult(
+ TEST_BUCKET_NAME,
+ null,
+ null,
+ Owner.DEFAULT_OWNER,
+ false,
+ "my/key.txt",
+ 1000,
+ null,
+ Owner.DEFAULT_OWNER,
+ parts,
+ null,
+ StorageClass.STANDARD,
+ uploadId.toString(),
+ null
+ )
+ whenever(
+ multipartService.getMultipartUploadParts(
+ any(),
+ any(),
+ any(),
+ anyOrNull(),
+ eq(uploadId)
+ )
+ ).thenReturn(result)
+
+ val uri = UriComponentsBuilder
+ .fromUriString("/${TEST_BUCKET_NAME}/my/key.txt")
+ .queryParam("uploadId", uploadId)
+ .build()
+ .toString()
+
+ val response = restTemplate.exchange(
+ uri,
+ HttpMethod.GET,
+ HttpEntity.EMPTY,
+ String::class.java
+ )
+
+ assertThat(response.statusCode).isEqualTo(HttpStatus.OK)
+ assertThat(response.body).isEqualTo(MAPPER.writeValueAsString(result))
+ }
+
+ @Test
+ fun testListParts_WithParams_PropagateCorrectly() {
+ val bucketMeta = bucketMetadata(versioningEnabled = false)
+ whenever(bucketService.verifyBucketExists(TEST_BUCKET_NAME)).thenReturn(bucketMeta)
+ val uploadId = UUID.randomUUID()
+
+ val maxParts = 5
+ val partNumberMarker = 3
+
+ val parts = listOf(createPart(4, 5L), createPart(5, 6L))
+ val result = ListPartsResult(
+ TEST_BUCKET_NAME,
+ null,
+ null,
+ Owner.DEFAULT_OWNER,
+ false,
+ "my/key.txt",
+ maxParts,
+ null,
+ Owner.DEFAULT_OWNER,
+ parts,
+ partNumberMarker,
+ StorageClass.STANDARD,
+ uploadId.toString(),
+ null
+ )
+
+ whenever(
+ multipartService.getMultipartUploadParts(
+ eq(TEST_BUCKET_NAME),
+ eq("my/key.txt"),
+ eq(maxParts),
+ eq(partNumberMarker),
+ eq(uploadId)
+ )
+ ).thenReturn(result)
+
+ val uri = UriComponentsBuilder
+ .fromUriString("/${TEST_BUCKET_NAME}/my/key.txt")
+ .queryParam("uploadId", uploadId)
+ .queryParam("max-parts", maxParts)
+ .queryParam("part-number-marker", partNumberMarker)
+ .build()
+ .toString()
+
+ val response = restTemplate.exchange(
+ uri,
+ HttpMethod.GET,
+ HttpEntity.EMPTY,
+ String::class.java
+ )
+
+ assertThat(response.statusCode).isEqualTo(HttpStatus.OK)
+ assertThat(response.body).isEqualTo(MAPPER.writeValueAsString(result))
+ }
+
+ @Test
+ fun testListParts_Pagination_ResponseFields() {
+ val bucketMeta = bucketMetadata(versioningEnabled = false)
+ whenever(bucketService.verifyBucketExists(TEST_BUCKET_NAME)).thenReturn(bucketMeta)
+ val uploadId = UUID.randomUUID()
+
+ val maxParts = 1
+ val partNumberMarker = 1
+ val nextPartNumberMarker = 2
+
+ val parts = listOf(createPart(2, 6L))
+ val result = ListPartsResult(
+ TEST_BUCKET_NAME,
+ null,
+ null,
+ Owner.DEFAULT_OWNER,
+ true,
+ "my/key.txt",
+ maxParts,
+ nextPartNumberMarker,
+ Owner.DEFAULT_OWNER,
+ parts,
+ partNumberMarker,
+ StorageClass.STANDARD,
+ uploadId.toString(),
+ null
+ )
+
+ whenever(
+ multipartService.getMultipartUploadParts(
+ eq(TEST_BUCKET_NAME),
+ eq("my/key.txt"),
+ eq(maxParts),
+ eq(partNumberMarker),
+ eq(uploadId)
+ )
+ ).thenReturn(result)
+
+ val uri = UriComponentsBuilder
+ .fromUriString("/${TEST_BUCKET_NAME}/my/key.txt")
+ .queryParam("uploadId", uploadId)
+ .queryParam("max-parts", maxParts)
+ .queryParam("part-number-marker", partNumberMarker)
+ .build()
+ .toString()
+
+ val response = restTemplate.exchange(
+ uri,
+ HttpMethod.GET,
+ HttpEntity.EMPTY,
+ String::class.java
+ )
+
+ assertThat(response.statusCode).isEqualTo(HttpStatus.OK)
+ assertThat(response.body).isEqualTo(MAPPER.writeValueAsString(result))
+ }
+
+ @Test
+ fun testListParts_NoSuchBucket() {
+ doThrow(S3Exception.NO_SUCH_BUCKET)
+ .whenever(bucketService)
+ .verifyBucketExists(TEST_BUCKET_NAME)
+
+ val uploadId = UUID.randomUUID()
+ val uri = UriComponentsBuilder
+ .fromUriString("/${TEST_BUCKET_NAME}/my/key.txt")
+ .queryParam("uploadId", uploadId)
+ .build()
+ .toString()
+
+ val response = restTemplate.exchange(
+ uri,
+ HttpMethod.GET,
+ HttpEntity.EMPTY,
+ String::class.java
+ )
+
+ assertThat(response.statusCode).isEqualTo(HttpStatus.NOT_FOUND)
+ assertThat(response.body).isEqualTo(MAPPER.writeValueAsString(from(S3Exception.NO_SUCH_BUCKET)))
+ }
+
+ @Test
+ fun testListParts_NoSuchUpload() {
+ val bucketMeta = bucketMetadata(versioningEnabled = false)
+ whenever(bucketService.verifyBucketExists(TEST_BUCKET_NAME)).thenReturn(bucketMeta)
+
+ val uploadId = UUID.randomUUID()
+ doThrow(S3Exception.NO_SUCH_UPLOAD_MULTIPART)
+ .whenever(multipartService)
+ .verifyMultipartUploadExists(TEST_BUCKET_NAME, uploadId)
+
+ val uri = UriComponentsBuilder
+ .fromUriString("/${TEST_BUCKET_NAME}/my/key.txt")
+ .queryParam("uploadId", uploadId)
+ .build()
+ .toString()
+
+ val response = restTemplate.exchange(
+ uri,
+ HttpMethod.GET,
+ HttpEntity.EMPTY,
+ String::class.java
+ )
+
+ assertThat(response.statusCode).isEqualTo(HttpStatus.NOT_FOUND)
+ assertThat(response.body).isEqualTo(MAPPER.writeValueAsString(from(S3Exception.NO_SUCH_UPLOAD_MULTIPART)))
+ }
+
+ @Test
+ fun testUploadPart_Ok_EtagReturned() {
+ val bucketMeta = bucketMetadata(versioningEnabled = false)
+ whenever(bucketService.verifyBucketExists(TEST_BUCKET_NAME)).thenReturn(bucketMeta)
+ val uploadId = UUID.randomUUID()
+
+ val temp = java.nio.file.Files.createTempFile("junie", "part")
+ whenever(multipartService.toTempFile(any(), any())).thenReturn(Pair.of(temp, null))
+ whenever(
+ multipartService.putPart(eq(TEST_BUCKET_NAME), eq("my/key.txt"), eq(uploadId), eq("1"), eq(temp), any())
+ ).thenReturn("etag-123")
+
+ val headers = HttpHeaders()
+ val uri = UriComponentsBuilder
+ .fromUriString("/${TEST_BUCKET_NAME}/my/key.txt")
+ .queryParam("uploadId", uploadId)
+ .queryParam("partNumber", 1)
+ .build()
+ .toString()
+
+ val response = restTemplate.exchange(
+ uri,
+ HttpMethod.PUT,
+ HttpEntity("payload-bytes", headers),
+ String::class.java
+ )
+
+ assertThat(response.statusCode).isEqualTo(HttpStatus.OK)
+ assertThat(response.headers.eTag).isEqualTo("\"etag-123\"")
+ }
+
+ @Test
+ fun testUploadPartCopy_Ok_VersionIdHeaderWhenVersioned() {
+ val bucketMeta = bucketMetadata(versioningEnabled = true)
+ whenever(bucketService.verifyBucketExists(TEST_BUCKET_NAME)).thenReturn(bucketMeta)
+
+ val s3meta = s3ObjectMetadata(
+ key = "source/key.txt",
+ id = UUID.randomUUID().toString(),
+ versionId = "v1"
+ )
+ whenever(
+ objectService.verifyObjectExists(
+ eq("source-bucket"),
+ eq("source/key.txt"),
+ eq("v1")
+ )
+ ).thenReturn(s3meta)
+
+ val copyResult = CopyPartResult(Date(), "etag-xyz")
+ whenever(
+ multipartService.copyPart(
+ any(),
+ any(),
+ anyOrNull(),
+ eq("1"),
+ any(),
+ any(),
+ any(),
+ any