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 [![IntelliJ IDEA logo.](https://resources.jetbrains.com/storage/products/company/brand/logos/IntelliJ_IDEA.svg)](https://jb.gg/OpenSourceSupport) + +## Star History +[![Star History Chart](https://api.star-history.com/svg?repos=adobe/S3Mock&type=Date)](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>(), + any() + ) + ).thenReturn(copyResult) + + val headers = HttpHeaders().apply { + add("x-amz-copy-source", "/source-bucket/source/key.txt?versionId=v1") + // Optional: no range or match headers + } + + val uploadId = UUID.randomUUID() + val uri = UriComponentsBuilder + .fromUriString("/${TEST_BUCKET_NAME}/dest/key.txt") + .queryParam("uploadId", uploadId) + .queryParam("partNumber", 1) + .build() + .toString() + + val response = restTemplate.exchange( + uri, + HttpMethod.PUT, + HttpEntity>(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(copyResult)) + } + + @Test + fun testUploadPartCopy_NoSuchBucket() { + doThrow(S3Exception.NO_SUCH_BUCKET) + .whenever(bucketService) + .verifyBucketExists(TEST_BUCKET_NAME) + + val headers = HttpHeaders().apply { + add("x-amz-copy-source", "/source-bucket/source/key.txt") + } + val uploadId = UUID.randomUUID() + val uri = UriComponentsBuilder + .fromUriString("/${TEST_BUCKET_NAME}/dest/key.txt") + .queryParam("uploadId", uploadId) + .queryParam("partNumber", 1) + .build() + .toString() + + val response = restTemplate.exchange( + uri, + HttpMethod.PUT, + HttpEntity("", headers), + String::class.java + ) + + assertThat(response.statusCode).isEqualTo(HttpStatus.NOT_FOUND) + assertThat(response.body).isEqualTo(MAPPER.writeValueAsString(from(S3Exception.NO_SUCH_BUCKET))) + } + + @Test + fun testUploadPartCopy_InvalidPartNumber_BadRequest() { + val bucketMeta = bucketMetadata(versioningEnabled = false) + whenever(bucketService.verifyBucketExists(TEST_BUCKET_NAME)).thenReturn(bucketMeta) + + doThrow(S3Exception.INVALID_PART_NUMBER) + .whenever(multipartService) + .verifyPartNumberLimits("1") + + val headers = HttpHeaders().apply { + add("x-amz-copy-source", "/source-bucket/source/key.txt") + } + val uploadId = UUID.randomUUID() + val uri = UriComponentsBuilder + .fromUriString("/${TEST_BUCKET_NAME}/dest/key.txt") + .queryParam("uploadId", uploadId) + .queryParam("partNumber", 1) + .build() + .toString() + + val response = restTemplate.exchange( + uri, + HttpMethod.PUT, + HttpEntity("", headers), + String::class.java + ) + + assertThat(response.statusCode).isEqualTo(HttpStatus.BAD_REQUEST) + assertThat(response.body).isEqualTo(MAPPER.writeValueAsString(from(S3Exception.INVALID_PART_NUMBER))) + } + + @Test + fun testUploadPartCopy_SourceObjectNotFound() { + val bucketMeta = bucketMetadata(versioningEnabled = false) + whenever(bucketService.verifyBucketExists(TEST_BUCKET_NAME)).thenReturn(bucketMeta) + + doThrow(S3Exception.NO_SUCH_KEY) + .whenever(objectService) + .verifyObjectExists(eq("source-bucket"), eq("source/key.txt"), anyOrNull()) + + val headers = HttpHeaders().apply { + add("x-amz-copy-source", "/source-bucket/source/key.txt") + } + val uploadId = UUID.randomUUID() + val uri = UriComponentsBuilder + .fromUriString("/${TEST_BUCKET_NAME}/dest/key.txt") + .queryParam("uploadId", uploadId) + .queryParam("partNumber", 1) + .build() + .toString() + + val response = restTemplate.exchange( + uri, + HttpMethod.PUT, + HttpEntity("", headers), + String::class.java + ) + + assertThat(response.statusCode).isEqualTo(HttpStatus.NOT_FOUND) + assertThat(response.body).isEqualTo(MAPPER.writeValueAsString(from(S3Exception.NO_SUCH_KEY))) + } + + @Test + fun testUploadPartCopy_PreconditionFailed() { + val bucketMeta = bucketMetadata(versioningEnabled = false) + whenever(bucketService.verifyBucketExists(TEST_BUCKET_NAME)).thenReturn(bucketMeta) + + val s3meta = s3ObjectMetadata("source/key.txt", UUID.randomUUID().toString()) + whenever(objectService.verifyObjectExists(eq("source-bucket"), eq("source/key.txt"), anyOrNull())) + .thenReturn(s3meta) + + // Simulate precondition failed on matching + doThrow(S3Exception.PRECONDITION_FAILED) + .whenever(objectService) + .verifyObjectMatchingForCopy( + anyOrNull(), + anyOrNull(), + anyOrNull(), + anyOrNull(), + eq(s3meta) + ) + + val headers = HttpHeaders().apply { + add("x-amz-copy-source", "/source-bucket/source/key.txt") + add("x-amz-copy-source-if-match", "etag-not-matching") + } + val uploadId = UUID.randomUUID() + val uri = UriComponentsBuilder + .fromUriString("/${TEST_BUCKET_NAME}/dest/key.txt") + .queryParam("uploadId", uploadId) + .queryParam("partNumber", 1) + .build() + .toString() + + val response = restTemplate.exchange( + uri, + HttpMethod.PUT, + HttpEntity("", headers), + String::class.java + ) + + assertThat(response.statusCode).isEqualTo(HttpStatus.PRECONDITION_FAILED) + assertThat(response.body).isEqualTo(MAPPER.writeValueAsString(from(S3Exception.PRECONDITION_FAILED))) + } + + @Test + fun testUploadPartCopy_NoVersionHeaderWhenNotVersioned() { + val bucketMeta = bucketMetadata(versioningEnabled = false) + 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>(), any() + ) + ).thenReturn(copyResult) + + val headers = HttpHeaders().apply { + add("x-amz-copy-source", "/source-bucket/source/key.txt?versionId=v1") + } + val uploadId = UUID.randomUUID() + val uri = UriComponentsBuilder + .fromUriString("/${TEST_BUCKET_NAME}/dest/key.txt") + .queryParam("uploadId", uploadId) + .queryParam("partNumber", 1) + .build() + .toString() + + val response = restTemplate.exchange( + uri, + HttpMethod.PUT, + HttpEntity("", headers), + String::class.java + ) + + assertThat(response.statusCode).isEqualTo(HttpStatus.OK) + // when versioning is disabled, controller should not echo x-amz-version-id + assertThat(response.headers.getFirst("x-amz-version-id")).isNull() + assertThat(response.body).isEqualTo(MAPPER.writeValueAsString(copyResult)) + } + + @Test + fun testUploadPartCopy_EncryptionHeadersEchoed() { + val bucketMeta = bucketMetadata(versioningEnabled = false) + whenever(bucketService.verifyBucketExists(TEST_BUCKET_NAME)).thenReturn(bucketMeta) + + val s3meta = s3ObjectMetadata("source/key.txt", UUID.randomUUID().toString()) + whenever(objectService.verifyObjectExists(eq("source-bucket"), eq("source/key.txt"), anyOrNull())) + .thenReturn(s3meta) + + val copyResult = CopyPartResult(Date(), "etag-enc") + val uploadId = UUID.randomUUID() + whenever( + multipartService.copyPart( + eq("source-bucket"), + eq("source/key.txt"), + anyOrNull(), + eq("1"), + eq(TEST_BUCKET_NAME), + eq("dest/key.txt"), + eq(uploadId), + eq(mapOf("x-amz-server-side-encryption" to "AES256")), + anyOrNull() + ) + ).thenReturn(copyResult) + + val headers = HttpHeaders().apply { + add("x-amz-copy-source", "/source-bucket/source/key.txt") + // Only headers starting with x-amz-server-side-encryption are echoed + add("x-amz-server-side-encryption", "AES256") + } + + val uri = UriComponentsBuilder + .fromUriString("/${TEST_BUCKET_NAME}/dest/key.txt") + .queryParam("uploadId", uploadId) + .queryParam("partNumber", 1) + .build() + .toString() + + val response = restTemplate.exchange( + uri, + HttpMethod.PUT, + HttpEntity("", 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(copyResult)) + } + + @Test + fun testUploadPart_WithHeaderChecksum_VerifiedAndReturned() { + 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)) + + // when checksum headers are present, controller should call verifyChecksum and return header + val checksum = "abc123checksum" + val headers = HttpHeaders().apply { + add("x-amz-checksum-algorithm", "SHA256") + add("x-amz-checksum-sha256", checksum) + } + + whenever( + multipartService.putPart(eq(TEST_BUCKET_NAME), eq("my/key.txt"), eq(uploadId), eq("1"), eq(temp), any()) + ).thenReturn("etag-321") + + 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-321\"") + // checksum header should be echoed + assertThat(response.headers.getFirst("x-amz-checksum-sha256")).isEqualTo(checksum) + } + + @Test + fun testUploadPart_InvalidPartNumber_BadRequest() { + // Arrange: toTempFile is called before validations + val temp = java.nio.file.Files.createTempFile("junie", "part") + whenever(multipartService.toTempFile(any(), any())).thenReturn(Pair.of(temp, null)) + + val bucketMeta = bucketMetadata(versioningEnabled = false) + whenever(bucketService.verifyBucketExists(TEST_BUCKET_NAME)).thenReturn(bucketMeta) + + val uploadId = UUID.randomUUID() + // Simulate invalid part number + doThrow(S3Exception.INVALID_PART_NUMBER) + .whenever(multipartService) + .verifyPartNumberLimits("1") + + val headers = HttpHeaders() + val uri = UriComponentsBuilder + .fromUriString("/${TEST_BUCKET_NAME}/my/key.txt") + .queryParam("uploadId", uploadId) + .queryParam("partNumber", 1) + .build() + .toString() + + // Act + val response = restTemplate.exchange( + uri, + HttpMethod.PUT, + HttpEntity("payload-bytes", headers), + String::class.java + ) + + // Assert + assertThat(response.statusCode).isEqualTo(HttpStatus.BAD_REQUEST) + assertThat(response.body).isEqualTo(MAPPER.writeValueAsString(from(S3Exception.INVALID_PART_NUMBER))) + } + + @Test + fun testUploadPart_NoSuchBucket() { + // toTempFile happens first + val temp = java.nio.file.Files.createTempFile("junie", "part") + whenever(multipartService.toTempFile(any(), any())).thenReturn(Pair.of(temp, null)) + + // bucket missing + 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) + .queryParam("partNumber", 1) + .build() + .toString() + + val response = restTemplate.exchange( + uri, + HttpMethod.PUT, + HttpEntity("payload-bytes", HttpHeaders()), + String::class.java + ) + + assertThat(response.statusCode).isEqualTo(HttpStatus.NOT_FOUND) + assertThat(response.body).isEqualTo(MAPPER.writeValueAsString(from(S3Exception.NO_SUCH_BUCKET))) + } + + @Test + fun testUploadPart_NoSuchUpload() { + val temp = java.nio.file.Files.createTempFile("junie", "part") + whenever(multipartService.toTempFile(any(), any())).thenReturn(Pair.of(temp, null)) + + 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) + .queryParam("partNumber", 1) + .build() + .toString() + + val response = restTemplate.exchange( + uri, + HttpMethod.PUT, + HttpEntity("payload-bytes", HttpHeaders()), + String::class.java + ) + + assertThat(response.statusCode).isEqualTo(HttpStatus.NOT_FOUND) + assertThat(response.body).isEqualTo(MAPPER.writeValueAsString(from(S3Exception.NO_SUCH_UPLOAD_MULTIPART))) + } + + @Test + fun testCreateMultipartUpload_Ok_ChecksumHeadersPropagated() { + val bucketMeta = bucketMetadata(versioningEnabled = false) + whenever(bucketService.verifyBucketExists(TEST_BUCKET_NAME)).thenReturn(bucketMeta) + + val result = InitiateMultipartUploadResult(TEST_BUCKET_NAME, "my/key.txt", "u-1") + whenever( + multipartService.createMultipartUpload( + eq(TEST_BUCKET_NAME), + eq("my/key.txt"), + eq("application/octet-stream"), + anyOrNull(), + eq(Owner.DEFAULT_OWNER), + eq(Owner.DEFAULT_OWNER), + anyOrNull>(), + anyOrNull>(), + anyOrNull>(), + eq(StorageClass.STANDARD), + eq(ChecksumType.FULL_OBJECT), + eq(ChecksumAlgorithm.SHA256) + ) + ).thenReturn(result) + + val headers = HttpHeaders().apply { + // supply checksum type and algorithm headers + add("x-amz-checksum-type", "FULL_OBJECT") + add("x-amz-checksum-algorithm", "SHA256") + add("Content-Type", "application/octet-stream") + } + + val uri = UriComponentsBuilder + .fromUriString("/${TEST_BUCKET_NAME}/my/key.txt") + .queryParam("uploads", "") + .build() + .toString() + + val response = restTemplate.exchange( + uri, + HttpMethod.POST, + HttpEntity("", headers), + String::class.java + ) + + assertThat(response.statusCode).isEqualTo(HttpStatus.OK) + assertThat(response.headers.getFirst("x-amz-checksum-algorithm")).isEqualTo("SHA256") + assertThat(response.headers.getFirst("x-amz-checksum-type")).isEqualTo("FULL_OBJECT") + assertThat(response.body).isEqualTo(MAPPER.writeValueAsString(result)) + } + + @Test + fun testCreateMultipartUpload_NoSuchBucket() { + // Arrange: bucket does not exist + doThrow(S3Exception.NO_SUCH_BUCKET) + .whenever(bucketService) + .verifyBucketExists(TEST_BUCKET_NAME) + + val uri = UriComponentsBuilder + .fromUriString("/${TEST_BUCKET_NAME}/my/key.txt") + .queryParam("uploads", "") + .build() + .toString() + + // Act + val response = restTemplate.exchange( + uri, + HttpMethod.POST, + HttpEntity("", HttpHeaders()), + String::class.java + ) + + // Assert + assertThat(response.statusCode).isEqualTo(HttpStatus.NOT_FOUND) + assertThat(response.body).isEqualTo(MAPPER.writeValueAsString(from(S3Exception.NO_SUCH_BUCKET))) + } + + @Test + fun testCreateMultipartUpload_EncryptionHeadersEchoed() { + val bucketMeta = bucketMetadata(versioningEnabled = false) + whenever(bucketService.verifyBucketExists(TEST_BUCKET_NAME)).thenReturn(bucketMeta) + + val result = InitiateMultipartUploadResult(TEST_BUCKET_NAME, "enc/key.txt", "u-enc-1") + whenever( + multipartService.createMultipartUpload( + eq(TEST_BUCKET_NAME), + eq("enc/key.txt"), + anyOrNull(), + anyOrNull(), + eq(Owner.DEFAULT_OWNER), + eq(Owner.DEFAULT_OWNER), + anyOrNull>(), + eq(mapOf("x-amz-server-side-encryption" to "AES256")), + anyOrNull>(), + eq(StorageClass.STANDARD), + anyOrNull(), + anyOrNull() + ) + ).thenReturn(result) + + val headers = HttpHeaders().apply { + add("x-amz-server-side-encryption", "AES256") + } + + val uri = UriComponentsBuilder + .fromUriString("/${TEST_BUCKET_NAME}/enc/key.txt") + .queryParam("uploads", "") + .build() + .toString() + + val response = restTemplate.exchange( + uri, + HttpMethod.POST, + HttpEntity("", 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)) + } + + @Test + fun testCreateMultipartUpload_StorageClass_Propagated() { + val bucketMeta = bucketMetadata(versioningEnabled = false) + whenever(bucketService.verifyBucketExists(TEST_BUCKET_NAME)).thenReturn(bucketMeta) + + val result = InitiateMultipartUploadResult(TEST_BUCKET_NAME, "sc/key.txt", "u-sc-1") + whenever( + multipartService.createMultipartUpload( + eq(TEST_BUCKET_NAME), + eq("sc/key.txt"), + anyOrNull(), + anyOrNull(), + eq(Owner.DEFAULT_OWNER), + eq(Owner.DEFAULT_OWNER), + anyOrNull>(), + anyOrNull>(), + anyOrNull>(), + eq(StorageClass.GLACIER), + anyOrNull(), + anyOrNull() + ) + ).thenReturn(result) + + val headers = HttpHeaders().apply { + add("x-amz-storage-class", "GLACIER") + } + + val uri = UriComponentsBuilder + .fromUriString("/${TEST_BUCKET_NAME}/sc/key.txt") + .queryParam("uploads", "") + .build() + .toString() + + val response = restTemplate.exchange( + uri, + HttpMethod.POST, + HttpEntity("", headers), + String::class.java + ) + + assertThat(response.statusCode).isEqualTo(HttpStatus.OK) + assertThat(response.body).isEqualTo(MAPPER.writeValueAsString(result)) + } + + @Test + fun testCreateMultipartUpload_NoContentType_PassesNull() { + val bucketMeta = bucketMetadata(versioningEnabled = false) + whenever(bucketService.verifyBucketExists(TEST_BUCKET_NAME)).thenReturn(bucketMeta) + + val result = InitiateMultipartUploadResult(TEST_BUCKET_NAME, "noct/key.txt", "u-noct-1") + whenever( + multipartService.createMultipartUpload( + eq(TEST_BUCKET_NAME), + eq("noct/key.txt"), + eq(null), + anyOrNull(), + eq(Owner.DEFAULT_OWNER), + eq(Owner.DEFAULT_OWNER), + anyOrNull>(), + anyOrNull>(), + anyOrNull>(), + eq(StorageClass.STANDARD), + anyOrNull(), + anyOrNull() + ) + ).thenReturn(result) + + val headers = HttpHeaders() // no Content-Type header + + val uri = UriComponentsBuilder + .fromUriString("/${TEST_BUCKET_NAME}/noct/key.txt") + .queryParam("uploads", "") + .build() + .toString() + + val response = restTemplate.exchange( + uri, + HttpMethod.POST, + HttpEntity>(headers), + String::class.java + ) + + assertThat(response.statusCode).isEqualTo(HttpStatus.OK) + 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) + } + + 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") + ) + + + private fun createPart(partNumber: Int, size: Long): Part { + return Part(partNumber, "someEtag$partNumber", Date(), size) + } + + private fun from(e: S3Exception): ErrorResponse { + return ErrorResponse( + e.code, + e.message, + null, + null + ) + } + + private fun bucketMetadata(versioningEnabled: Boolean): BucketMetadata { + val versioning = if (versioningEnabled) VersioningConfiguration(null, VersioningConfiguration.Status.ENABLED, null) else null + return BucketMetadata( + TEST_BUCKET_NAME, + Instant.now().toString(), + versioning, + null, + null, + null, + Paths.get("/tmp/foo/1"), + "us-east-1", + null, + null + ) + } + + private fun s3ObjectMetadata( + key: String, + id: String, + versionId: String? = null + ): S3ObjectMetadata { + return S3ObjectMetadata( + UUID.fromString(id), + key, + "0", + Instant.now().toString(), + "etag", + "application/octet-stream", + System.currentTimeMillis(), + Paths.get("/tmp/foo/1/$key"), + emptyMap(), + emptyList(), + null, + null, + Owner.DEFAULT_OWNER, + emptyMap(), + emptyMap(), + null, + null, + null, + null, + versionId, + false, + ChecksumType.FULL_OBJECT + ) + } } } diff --git a/server/src/test/kotlin/com/adobe/testing/s3mock/ObjectControllerTest.kt b/server/src/test/kotlin/com/adobe/testing/s3mock/ObjectControllerTest.kt index 406b4fed9..0133aee0b 100644 --- a/server/src/test/kotlin/com/adobe/testing/s3mock/ObjectControllerTest.kt +++ b/server/src/test/kotlin/com/adobe/testing/s3mock/ObjectControllerTest.kt @@ -18,15 +18,23 @@ package com.adobe.testing.s3mock import com.adobe.testing.s3mock.dto.AccessControlPolicy import com.adobe.testing.s3mock.dto.Bucket import com.adobe.testing.s3mock.dto.CanonicalUser +import com.adobe.testing.s3mock.dto.ChecksumAlgorithm import com.adobe.testing.s3mock.dto.ChecksumType +import com.adobe.testing.s3mock.dto.Delete +import com.adobe.testing.s3mock.dto.DeleteResult +import com.adobe.testing.s3mock.dto.DeletedS3Object +import com.adobe.testing.s3mock.dto.GetObjectAttributesOutput import com.adobe.testing.s3mock.dto.Grant +import com.adobe.testing.s3mock.dto.LegalHold import com.adobe.testing.s3mock.dto.Mode import com.adobe.testing.s3mock.dto.Owner import com.adobe.testing.s3mock.dto.Retention +import com.adobe.testing.s3mock.dto.S3ObjectIdentifier import com.adobe.testing.s3mock.dto.StorageClass import com.adobe.testing.s3mock.dto.Tag import com.adobe.testing.s3mock.dto.TagSet import com.adobe.testing.s3mock.dto.Tagging +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 @@ -50,14 +58,15 @@ import org.mockito.kotlin.verify 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.core.io.ByteArrayResource 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.LinkedMultiValueMap import org.springframework.web.util.UriComponentsBuilder import software.amazon.awssdk.checksums.DefaultChecksumAlgorithm import java.io.File @@ -68,17 +77,13 @@ import java.nio.file.Paths import java.time.Instant import java.util.UUID -@MockBeans( - MockBean( - classes = [KmsKeyStore::class, MultipartService::class, BucketController::class, MultipartController::class] - ) -) +@MockitoBean(types = [KmsKeyStore::class, MultipartService::class, BucketController::class, MultipartController::class]) @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) internal class ObjectControllerTest : BaseControllerTest() { - @MockBean + @MockitoBean private lateinit var objectService: ObjectService - @MockBean + @MockitoBean private lateinit var bucketService: BucketService @Autowired @@ -474,8 +479,9 @@ internal class ObjectControllerTest : BaseControllerTest() { ) ) val s3ObjectMetadata = s3ObjectMetadata( - key, UUID.randomUUID().toString(), - null, null, null, tagging.tagSet.tags + key, + UUID.randomUUID().toString(), + tags = tagging.tagSet.tags ) whenever(objectService.verifyObjectExists("test-bucket", key, null)) .thenReturn(s3ObjectMetadata) @@ -543,8 +549,9 @@ internal class ObjectControllerTest : BaseControllerTest() { val instant = Instant.ofEpochMilli(1514477008120L) val retention = Retention(Mode.COMPLIANCE, instant) val s3ObjectMetadata = s3ObjectMetadata( - key, UUID.randomUUID().toString(), - null, null, retention, null + key, + UUID.randomUUID().toString(), + retention = retention, ) whenever(objectService.verifyObjectLockConfiguration("test-bucket", key, null)) .thenReturn(s3ObjectMetadata) @@ -596,7 +603,842 @@ internal class ObjectControllerTest : BaseControllerTest() { assertThat(response.statusCode).isEqualTo(HttpStatus.OK) } - private fun givenBucket() { + @Test + @Throws(Exception::class) + fun testGetObject_Range_Ok() { + givenBucket() + val key = "sampleFile.txt" + val testFile = File(UPLOAD_FILE_NAME) + val digest = DigestUtil.hexDigest(Files.newInputStream(testFile.toPath())) + + whenever(objectService.verifyObjectExists("test-bucket", key, null)) + .thenReturn(s3ObjectMetadata(key, digest)) + + val headers = HttpHeaders().apply { + this.accept = listOf(MediaType.ALL) + this.set("Range", "bytes=1-2") + } + + val response = restTemplate.exchange( + "/test-bucket/$key", + HttpMethod.GET, + HttpEntity(headers), + ByteArray::class.java + ) + + assertThat(response.statusCode).isEqualTo(HttpStatus.PARTIAL_CONTENT) + val total = testFile.length() + assertThat(response.headers.getFirst(HttpHeaders.CONTENT_RANGE)).isEqualTo("bytes 1-2/$total") + assertThat(response.headers.getFirst(HttpHeaders.ACCEPT_RANGES)).isEqualTo("bytes") + assertThat(response.headers.contentLength).isEqualTo(2) + assertThat(response.headers.eTag).isEqualTo("\"$digest\"") + } + + @Test + fun testDeleteObjectTagging_NoContent() { + givenBucket() + val key = "name" + val s3ObjectMetadata = s3ObjectMetadata(key, UUID.randomUUID().toString()) + whenever(objectService.verifyObjectExists("test-bucket", key, null)).thenReturn(s3ObjectMetadata) + + val headers = HttpHeaders().apply { + this.accept = listOf(MediaType.APPLICATION_XML) + } + val uri = UriComponentsBuilder + .fromUriString("/test-bucket/$key") + .queryParam(AwsHttpParameters.TAGGING, "ignored") + .build() + .toString() + + val response = restTemplate.exchange( + uri, + HttpMethod.DELETE, + HttpEntity(headers), + String::class.java + ) + + assertThat(response.statusCode).isEqualTo(HttpStatus.NO_CONTENT) + verify(objectService).setObjectTags("test-bucket", key, null, null) + } + + @Test + fun testGetLegalHold_Ok() { + givenBucket() + val key = "locked" + val legalHold = LegalHold(LegalHold.Status.ON) + val metadata = s3ObjectMetadata( + key, + UUID.randomUUID().toString(), + legalHold = legalHold + ) + whenever(objectService.verifyObjectExists("test-bucket", key, null)).thenReturn(metadata) + whenever(objectService.verifyObjectLockConfiguration("test-bucket", key, null)).thenReturn(metadata) + + val headers = HttpHeaders().apply { + this.accept = listOf(MediaType.APPLICATION_XML) + this.contentType = MediaType.APPLICATION_XML + } + val uri = UriComponentsBuilder + .fromUriString("/test-bucket/$key") + .queryParam(AwsHttpParameters.LEGAL_HOLD, "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(legalHold)) + } + + @Test + fun testPutLegalHold_Ok() { + givenBucket() + val key = "locked" + val legalHold = LegalHold(LegalHold.Status.OFF) + + val headers = HttpHeaders().apply { + this.accept = listOf(MediaType.APPLICATION_XML) + this.contentType = MediaType.APPLICATION_XML + } + val uri = UriComponentsBuilder + .fromUriString("/test-bucket/$key") + .queryParam(AwsHttpParameters.LEGAL_HOLD, "ignored") + .build() + .toString() + + val response = restTemplate.exchange( + uri, + HttpMethod.PUT, + HttpEntity(MAPPER.writeValueAsString(legalHold), headers), + String::class.java + ) + + assertThat(response.statusCode).isEqualTo(HttpStatus.OK) + verify(objectService).setLegalHold("test-bucket", key, null, legalHold) + } + + @Test + fun testGetObjectAttributes_Ok() { + givenBucket() + val key = "attrs.txt" + val testFile = File(UPLOAD_FILE_NAME) + val hex = DigestUtil.hexDigest(Files.newInputStream(testFile.toPath())) + val metadata = s3ObjectMetadata(key, hex) + whenever(objectService.verifyObjectExists("test-bucket", key, null)).thenReturn(metadata) + + val headers = HttpHeaders().apply { + this.accept = listOf(MediaType.APPLICATION_XML) + this.contentType = MediaType.APPLICATION_XML + this.add(AwsHttpHeaders.X_AMZ_OBJECT_ATTRIBUTES, "ETag,Checksum,ObjectSize,StorageClass") + } + val uri = UriComponentsBuilder + .fromUriString("/test-bucket/$key") + .queryParam(AwsHttpParameters.ATTRIBUTES, "ignored") + .build() + .toString() + + val response = restTemplate.exchange( + uri, + HttpMethod.GET, + HttpEntity(headers), + String::class.java + ) + + val expected = GetObjectAttributesOutput( + null, + hex, + null, + testFile.length(), + StorageClass.STANDARD + ) + assertThat(response.statusCode).isEqualTo(HttpStatus.OK) + assertThat(response.body).isEqualTo(MAPPER.writeValueAsString(expected)) + } + + @Test + fun testDeleteObjects_Ok() { + givenBucket() + val body = Delete( + listOf( + S3ObjectIdentifier("a", "etag", "0", "1", "v1"), + S3ObjectIdentifier("b", "etag2", "0", "2", "v2") + ), + false + ) + val expected = DeleteResult( + emptyList(), + listOf( + DeletedS3Object(null, null, "a", "v1"), + DeletedS3Object(null, null, "b", "v2") + ) + ) + whenever(objectService.deleteObjects("test-bucket", body)).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.DELETE, "ignored") + .build() + .toString() + + val response = restTemplate.exchange( + uri, + HttpMethod.POST, + HttpEntity(MAPPER.writeValueAsString(body), headers), + String::class.java + ) + + assertThat(response.statusCode).isEqualTo(HttpStatus.OK) + assertThat(response.body).isEqualTo(MAPPER.writeValueAsString(expected)) + } + + @Test + fun testCopyObject_Ok_WithVersioningHeaders() { + // Target and source buckets with versioning enabled + val targetBucket = "test-bucket" + val sourceBucket = "source-bucket" + val sourceKey = "src.txt" + val targetKey = "dst.txt" + val sourceVersion = "sv1" + + // Configure buckets + val versioningConfiguration = VersioningConfiguration( + VersioningConfiguration.MFADelete.DISABLED, + VersioningConfiguration.Status.ENABLED, + null + ) + val versioningBucket = bucketMetadata( + targetBucket, + versioningConfiguration = versioningConfiguration, + ) + val versioningSourceBucket = bucketMetadata( + sourceBucket, + versioningConfiguration = versioningConfiguration, + ) + + whenever(bucketService.verifyBucketExists(targetBucket)).thenReturn(versioningBucket) + whenever(bucketService.verifyBucketExists(sourceBucket)).thenReturn(versioningSourceBucket) + + val srcMeta = s3ObjectMetadata(sourceKey, UUID.randomUUID().toString()) + whenever(objectService.verifyObjectExists(sourceBucket, sourceKey, sourceVersion)).thenReturn(srcMeta) + + val copiedMeta = s3ObjectMetadata( + targetKey, + versionId = "tv1" + ) + whenever( + objectService.copyS3Object( + eq(sourceBucket), eq(sourceKey), eq(sourceVersion), + eq(targetBucket), eq(targetKey), anyMap(), anyMap(), anyMap(), isNull() + ) + ).thenReturn(copiedMeta) + + val headers = HttpHeaders().apply { + this.accept = listOf(MediaType.APPLICATION_XML) + this.contentType = MediaType.APPLICATION_XML + // indicate REPLACE to test store/user headers path too (no specific headers asserted here) + this[AwsHttpHeaders.X_AMZ_METADATA_DIRECTIVE] = "REPLACE" + this[AwsHttpHeaders.X_AMZ_COPY_SOURCE] = "/$sourceBucket/$sourceKey?versionId=$sourceVersion" + } + + val response = restTemplate.exchange( + "/$targetBucket/$targetKey", + HttpMethod.PUT, + HttpEntity(null, headers), + String::class.java + ) + + assertThat(response.statusCode).isEqualTo(HttpStatus.OK) + // Source version header must be present + assertThat(response.headers[AwsHttpHeaders.X_AMZ_COPY_SOURCE_VERSION_ID]).containsExactly(sourceVersion) + // Target version header must be present (copy target version) + assertThat(response.headers[AwsHttpHeaders.X_AMZ_VERSION_ID]).containsExactly("tv1") + } + + @Test + fun testCopyObject_NotFound_PropagatesEncryptionHeaders() { + val targetBucket = "test-bucket" + val sourceBucket = "source-bucket" + val sourceKey = "src.txt" + val targetKey = "dst.txt" + + // Buckets exist + whenever(bucketService.verifyBucketExists(targetBucket)).thenReturn(TEST_BUCKETMETADATA) + whenever(bucketService.verifyBucketExists(sourceBucket)).thenReturn(TEST_BUCKETMETADATA) + + // Source object exists with encryption headers + val srcMeta = s3ObjectEncrypted(sourceKey, UUID.randomUUID().toString(), "aws:kms", "kms-key") + whenever(objectService.verifyObjectExists(sourceBucket, sourceKey, null)).thenReturn(srcMeta) + + // Service indicates not found (e.g., filtered out) by returning null + whenever( + objectService.copyS3Object( + eq(sourceBucket), eq(sourceKey), isNull(), + eq(targetBucket), eq(targetKey), anyMap(), anyMap(), anyMap(), isNull() + ) + ).thenReturn(null) + + val headers = HttpHeaders().apply { + this.accept = listOf(MediaType.APPLICATION_XML) + this.contentType = MediaType.APPLICATION_XML + this[AwsHttpHeaders.X_AMZ_COPY_SOURCE] = "/$sourceBucket/$sourceKey" + } + + val response = restTemplate.exchange( + "/$targetBucket/$targetKey", + HttpMethod.PUT, + HttpEntity(null, headers), + String::class.java + ) + + assertThat(response.statusCode).isEqualTo(HttpStatus.NOT_FOUND) + assertThat(response.headers[AwsHttpHeaders.X_AMZ_SERVER_SIDE_ENCRYPTION]).containsExactly("aws:kms") + assertThat(response.headers[AwsHttpHeaders.X_AMZ_SERVER_SIDE_ENCRYPTION_AWS_KMS_KEY_ID]).containsExactly("kms-key") + } + + @Test + fun testDeleteObject_Versioning_DeleteMarkerHeader() { + val bucket = "test-bucket" + val key = "to-delete.txt" + + // Bucket with versioning enabled + val versioningConfiguration = VersioningConfiguration( + VersioningConfiguration.MFADelete.DISABLED, + VersioningConfiguration.Status.ENABLED, + null + ) + val versioningBucket = bucketMetadata( + bucket, + versioningConfiguration = versioningConfiguration, + ) + whenever(bucketService.verifyBucketExists(bucket)).thenReturn(versioningBucket) + + val existingMeta = s3ObjectMetadata( + key, + versionId = "v1" + ) + // First verify call returns the object + whenever(objectService.verifyObjectExists(bucket, key, null)) + .thenReturn(existingMeta) + // Second call after delete simulates a delete marker response + .thenThrow(S3Exception.NO_SUCH_KEY_DELETE_MARKER) + + whenever(objectService.deleteObject(bucket, key, null)).thenReturn(true) + + val response = restTemplate.exchange( + "/$bucket/$key", + HttpMethod.DELETE, + HttpEntity(HttpHeaders()), + String::class.java + ) + + assertThat(response.statusCode).isEqualTo(HttpStatus.NO_CONTENT) + // Controller sets delete marker based on follow-up verify throwing NO_SUCH_KEY_DELETE_MARKER + assertThat(response.headers[AwsHttpHeaders.X_AMZ_DELETE_MARKER]).containsExactly("true") + // When versioning enabled and original metadata had versionId, it should be echoed + assertThat(response.headers[AwsHttpHeaders.X_AMZ_VERSION_ID]).containsExactly("v1") + } + + @Test + fun testPostObject_Ok_MinimalMultipart() { + val bucket = "test-bucket" + whenever(bucketService.verifyBucketExists(bucket)).thenReturn(TEST_BUCKETMETADATA) + + val key = "upload.txt" + val testFile = File(UPLOAD_FILE_NAME) + val tempFile = Files.createTempFile("postObject", "").also { testFile.copyTo(it.toFile(), overwrite = true) } + + // Single-arg overload used by postObject + whenever(objectService.toTempFile(any(InputStream::class.java))) + .thenReturn(Pair.of(tempFile, DigestUtil.checksumFor(testFile.toPath(), DefaultChecksumAlgorithm.CRC32))) + + val returned = s3ObjectMetadata(key, DigestUtil.hexDigest(Files.newInputStream(testFile.toPath()))) + whenever( + objectService.putS3Object( + eq(bucket), eq(key), any(), anyMap(), any(Path::class.java), anyMap(), anyMap(), isNull(), isNull(), isNull(), eq(Owner.DEFAULT_OWNER), isNull() + ) + ).thenReturn(returned) + + // Build multipart request + val fileResource = object : ByteArrayResource(testFile.readBytes()) { + override fun getFilename(): String = key + } + val parts = LinkedMultiValueMap() + parts.add("key", key) + parts.add("file", HttpEntity(fileResource)) + + val headers = HttpHeaders().apply { contentType = MediaType.MULTIPART_FORM_DATA } + + val response = restTemplate.postForEntity( + "/$bucket", + HttpEntity(parts, headers), + String::class.java + ) + + assertThat(response.statusCode).isEqualTo(HttpStatus.OK) + assertThat(response.headers.eTag).isEqualTo(returned.etag) + } + + @Test + fun testGetObject_ChecksumHeaders_WhenEnabled() { + givenBucket() + val key = "chk.txt" + val meta = s3ObjectMetadata( + key, + checksumAlgorithm = ChecksumAlgorithm.CRC32, + checksum = "abcd1234" + ) + + whenever(objectService.verifyObjectExists("test-bucket", key, null)).thenReturn(meta) + + val headers = HttpHeaders().apply { + this.accept = listOf(MediaType.ALL) + this[AwsHttpHeaders.X_AMZ_CHECKSUM_MODE] = "ENABLED" + } + + val response = restTemplate.exchange( + "/test-bucket/$key", + HttpMethod.GET, + HttpEntity(headers), + ByteArray::class.java + ) + + assertThat(response.statusCode).isEqualTo(HttpStatus.OK) + assertThat(response.headers[AwsHttpHeaders.X_AMZ_CHECKSUM_CRC32]) + .containsExactly("abcd1234") + } + + @Test + fun testHeadObject_OverrideHeaders_QueryParams() { + givenBucket() + val key = "ovr.txt" + val meta = s3ObjectMetadata(key) + whenever(objectService.verifyObjectExists("test-bucket", key, null)).thenReturn(meta) + + val contentDisposition = "attachment; filename=ovr.txt" + val contentType = "text/html" + val uri = UriComponentsBuilder + .fromUriString("/test-bucket/$key") + .queryParam("response-content-type", contentType) + .queryParam("response-content-disposition", contentDisposition) + .build() + .toString() + + val response = restTemplate.exchange( + uri, + HttpMethod.HEAD, + HttpEntity(HttpHeaders()), + Void::class.java + ) + + assertThat(response.statusCode).isEqualTo(HttpStatus.OK) + assertThat(response.headers.contentType?.toString()).isEqualTo(contentType) + assertThat(response.headers.getFirst(HttpHeaders.CONTENT_DISPOSITION)).isEqualTo(contentDisposition) + } + + @Test + fun testGetObject_Range_Invalid_416() { + givenBucket() + val key = "rng.txt" + val meta = s3ObjectMetadata(key) + whenever(objectService.verifyObjectExists("test-bucket", key, null)).thenReturn(meta) + + val headers = HttpHeaders().apply { this.set("Range", "bytes=9999999-10000000") } + val response = restTemplate.exchange( + "/test-bucket/$key", + HttpMethod.GET, + HttpEntity(headers), + String::class.java + ) + + assertThat(response.statusCode).isEqualTo(HttpStatus.REQUESTED_RANGE_NOT_SATISFIABLE) + } + + @Test + fun testPostObject_WithTaggingAndStorageClass() { + val bucket = "test-bucket" + whenever(bucketService.verifyBucketExists(bucket)).thenReturn(TEST_BUCKETMETADATA) + + val key = "upload-tags.txt" + val testFile = File(UPLOAD_FILE_NAME) + val tempFile = Files.createTempFile("postObjectTags", "").also { testFile.copyTo(it.toFile(), overwrite = true) } + + whenever(objectService.toTempFile(any(InputStream::class.java))) + .thenReturn(Pair.of(tempFile, DigestUtil.checksumFor(testFile.toPath(), DefaultChecksumAlgorithm.CRC32))) + + val tagging = Tagging(TagSet(listOf(Tag("k1", "v1"), Tag("k2", "v2")))) + val returned = s3ObjectMetadata(key, DigestUtil.hexDigest(Files.newInputStream(testFile.toPath()))) + whenever( + objectService.putS3Object( + eq(bucket), eq(key), any(), anyMap(), any(Path::class.java), anyMap(), anyMap(), any(), isNull(), isNull(), eq(Owner.DEFAULT_OWNER), eq(StorageClass.STANDARD) + ) + ).thenReturn(returned) + + val fileResource = object : ByteArrayResource(testFile.readBytes()) { override fun getFilename(): String = key } + val parts = LinkedMultiValueMap().apply { + add("key", key) + add("file", HttpEntity(fileResource)) + add("tagging", MAPPER.writeValueAsString(tagging)) + add("x-amz-storage-class", StorageClass.STANDARD.name) + } + + val headers = HttpHeaders().apply { contentType = MediaType.MULTIPART_FORM_DATA } + + val response = restTemplate.postForEntity( + "/$bucket", + HttpEntity(parts, headers), + String::class.java + ) + + assertThat(response.statusCode).isEqualTo(HttpStatus.OK) + assertThat(response.headers.eTag).isEqualTo(returned.etag) + // verify storage class and tags were passed + verify(objectService).putS3Object( + eq(bucket), eq(key), any(), anyMap(), any(Path::class.java), anyMap(), anyMap(), eq(tagging.tagSet.tags), isNull(), isNull(), eq(Owner.DEFAULT_OWNER), eq(StorageClass.STANDARD) + ) + } + + @Test + fun testPutObject_WithIfMatch_AndSdkChecksum() { + givenBucket() + val bucket = "test-bucket" + val key = "put-chksum.txt" + val src = File(UPLOAD_FILE_NAME) + val temp = Files.createTempFile("put-chk", "").also { src.copyTo(it.toFile(), overwrite = true) } + + // SDK checksum path: controller uses Right value from toTempFile + whenever(objectService.toTempFile(any(InputStream::class.java), any(HttpHeaders::class.java))) + .thenReturn(Pair.of(temp, "crc32Value")) + + // Returned metadata should include checksum to be echoed as header + val s3ObjectMetadata = s3ObjectMetadata( + key, + checksumAlgorithm = ChecksumAlgorithm.CRC32, + checksum = "crc32Value", + ) + + whenever( + objectService.putS3Object( + eq(bucket), eq(key), any(), anyMap(), any(Path::class.java), anyMap(), anyMap(), isNull(), eq(ChecksumAlgorithm.CRC32), eq("crc32Value"), eq(Owner.DEFAULT_OWNER), eq(StorageClass.STANDARD) + ) + ).thenReturn(s3ObjectMetadata) + + val headers = HttpHeaders().apply { + this[HttpHeaders.IF_MATCH] = listOf("\"etag-123\"") + this[AwsHttpHeaders.X_AMZ_SDK_CHECKSUM_ALGORITHM] = "CRC32" + contentType = MediaType.APPLICATION_OCTET_STREAM + } + + val resp = restTemplate.exchange( + "/$bucket/$key", + HttpMethod.PUT, + HttpEntity(src.readBytes(), headers), + String::class.java + ) + + assertThat(resp.statusCode).isEqualTo(HttpStatus.OK) + // checksum header echoed from metadata + assertThat(resp.headers[AwsHttpHeaders.X_AMZ_CHECKSUM_CRC32]).containsExactly("crc32Value") + // object size header present + assertThat(resp.headers[AwsHttpHeaders.X_AMZ_OBJECT_SIZE]).containsExactly(s3ObjectMetadata.size()) + // verify matching path used and checksum verification invoked + verify(objectService).verifyObjectMatching(eq(bucket), eq(key), any(), isNull()) + verify(objectService).verifyChecksum(eq(temp), eq("crc32Value"), eq(ChecksumAlgorithm.CRC32)) + } + + @Test + fun testGetObjectAttributes_Selective_WithChecksum() { + givenBucket() + val key = "ga.txt" + val s3ObjectMetadata = s3ObjectMetadata( + key, + checksumAlgorithm = ChecksumAlgorithm.CRC32C, + checksum = "crcc-value", + ) + + whenever(objectService.verifyObjectExists("test-bucket", key, null)).thenReturn(s3ObjectMetadata) + + val headers = HttpHeaders().apply { + this.accept = listOf(MediaType.APPLICATION_XML) + this[AwsHttpHeaders.X_AMZ_OBJECT_ATTRIBUTES] = "Checksum,ObjectSize" + } + + val uri = UriComponentsBuilder + .fromUriString("/test-bucket/$key") + .queryParam(AwsHttpParameters.ATTRIBUTES, "ignored") + .build() + .toString() + + val resp = restTemplate.exchange( + uri, + HttpMethod.GET, + HttpEntity(headers), + String::class.java + ) + + assertThat(resp.statusCode).isEqualTo(HttpStatus.OK) + val got = MAPPER.readValue(resp.body, GetObjectAttributesOutput::class.java) + // only selected fields should be present + assertThat(got.etag()).isNull() + assertThat(got.storageClass()).isNull() + assertThat(got.objectSize()).isEqualTo(s3ObjectMetadata.dataPath().toFile().length()) + assertThat(got.checksum().checksumCRC32C()).isEqualTo("crcc-value") + assertThat(got.checksum().checksumType()).isEqualTo(ChecksumType.FULL_OBJECT) + } + + @Test + fun testCopyObject_MetadataDirectiveCopy_WithConditionalHeaders() { + val targetBucket = "test-bucket" + val sourceBucket = "src-bucket" + val sourceKey = "a.txt" + val targetKey = "b.txt" + + // Buckets exist (no versioning required for this test) + whenever(bucketService.verifyBucketExists(targetBucket)).thenReturn(TEST_BUCKETMETADATA) + whenever(bucketService.verifyBucketExists(sourceBucket)).thenReturn(TEST_BUCKETMETADATA) + + // Source object exists + val srcMeta = s3ObjectMetadata(sourceKey) + whenever(objectService.verifyObjectExists(sourceBucket, sourceKey, null)).thenReturn(srcMeta) + + // Copy returns metadata + val copied = s3ObjectMetadata(targetKey) + whenever( + objectService.copyS3Object( + eq(sourceBucket), eq(sourceKey), isNull(), + eq(targetBucket), eq(targetKey), anyMap(), anyMap(), anyMap(), isNull() + ) + ).thenReturn(copied) + + val headers = HttpHeaders().apply { + this.accept = listOf(MediaType.APPLICATION_XML) + this.contentType = MediaType.APPLICATION_XML + this[AwsHttpHeaders.X_AMZ_COPY_SOURCE] = "/$sourceBucket/$sourceKey" + this[AwsHttpHeaders.X_AMZ_METADATA_DIRECTIVE] = "COPY" + this[AwsHttpHeaders.X_AMZ_COPY_SOURCE_IF_MATCH] = "\"etag-1\"" + this[AwsHttpHeaders.X_AMZ_COPY_SOURCE_IF_NONE_MATCH] = "\"etag-2\"" + this[AwsHttpHeaders.X_AMZ_COPY_SOURCE_IF_MODIFIED_SINCE] = Instant.now().toString() + this[AwsHttpHeaders.X_AMZ_COPY_SOURCE_IF_UNMODIFIED_SINCE] = Instant.now().minusSeconds(60).toString() + } + + val response = restTemplate.exchange( + "/$targetBucket/$targetKey", + HttpMethod.PUT, + HttpEntity(null, headers), + String::class.java + ) + + assertThat(response.statusCode).isEqualTo(HttpStatus.OK) + // verify conditional headers reached the service verifier + verify(objectService).verifyObjectMatchingForCopy( + eq(listOf("\"etag-1\"")), + eq(listOf("\"etag-2\"")), + any(), // instants parsed to list + any(), + eq(srcMeta) + ) + // verify copy called with COPY path (no user/store header replacements expected); we already set anyMap() above + verify(objectService).copyS3Object( + eq(sourceBucket), eq(sourceKey), isNull(), + eq(targetBucket), eq(targetKey), anyMap(), anyMap(), anyMap(), isNull() + ) + } + + @Test + fun testDeleteObject_MatchHeaders_DeletedFalse_InitialNoSuchKey() { + val bucket = "test-bucket" + val key = "to-del.txt" + + whenever(bucketService.verifyBucketExists(bucket)).thenReturn(TEST_BUCKETMETADATA) + + // Initial verification throws NO_SUCH_KEY (controller should ignore and continue) + doThrow(S3Exception.NO_SUCH_KEY).whenever(objectService).verifyObjectExists(bucket, key, null) + // Deletion reports false + whenever(objectService.deleteObject(bucket, key, null)).thenReturn(false) + + val lm = Instant.now() + val size = 123L + val headers = HttpHeaders().apply { + this[AwsHttpHeaders.X_AMZ_IF_MATCH_LAST_MODIFIED_TIME] = lm.toString() + this[AwsHttpHeaders.X_AMZ_IF_MATCH_SIZE] = size.toString() + } + + val response = restTemplate.exchange( + "/$bucket/$key", + HttpMethod.DELETE, + HttpEntity(headers), + String::class.java + ) + + assertThat(response.statusCode).isEqualTo(HttpStatus.NO_CONTENT) + assertThat(response.headers[AwsHttpHeaders.X_AMZ_DELETE_MARKER]).containsExactly("false") + + // verify match headers forwarded with null metadata + verify(objectService).verifyObjectMatching( + isNull(), + eq(listOf(lm)), + eq(listOf(size)), + isNull() + ) + } + + @Test + fun testHeadObject_VersioningHeader_Present() { + val bucket = "test-bucket" + val key = "vh.txt" + // bucket with versioning enabled + val versioningConfiguration = VersioningConfiguration( + VersioningConfiguration.MFADelete.DISABLED, + VersioningConfiguration.Status.ENABLED, + null + ) + val versioningBucket = bucketMetadata( + name = bucket, + versioningConfiguration = versioningConfiguration + ) + whenever(bucketService.verifyBucketExists(bucket)).thenReturn(versioningBucket) + + val meta = s3ObjectMetadata(key, versionId = "v-123") + whenever(objectService.verifyObjectExists(bucket, key, null)).thenReturn(meta) + + val resp = restTemplate.exchange( + "/$bucket/$key", + HttpMethod.HEAD, + HttpEntity(HttpHeaders()), + Void::class.java + ) + + assertThat(resp.statusCode).isEqualTo(HttpStatus.OK) + assertThat(resp.headers[AwsHttpHeaders.X_AMZ_VERSION_ID]).containsExactly("v-123") + } + + @Test + fun testGetObject_VersioningHeader_Present() { + val bucket = "test-bucket" + val key = "gv.txt" + val versioningConfiguration = VersioningConfiguration( + VersioningConfiguration.MFADelete.DISABLED, + VersioningConfiguration.Status.ENABLED, + null + ) + val versioningBucket = bucketMetadata( + name = bucket, + versioningConfiguration = versioningConfiguration + ) + whenever(bucketService.verifyBucketExists(bucket)).thenReturn(versioningBucket) + + val meta = s3ObjectMetadata(key, versionId = "v-9") + whenever(objectService.verifyObjectExists(bucket, key, null)).thenReturn(meta) + + val resp = restTemplate.exchange( + "/$bucket/$key", + HttpMethod.GET, + HttpEntity(HttpHeaders()), + ByteArray::class.java + ) + + assertThat(resp.statusCode).isEqualTo(HttpStatus.OK) + assertThat(resp.headers[AwsHttpHeaders.X_AMZ_VERSION_ID]).containsExactly("v-9") + } + + @Test + fun testGetObject_PropagatesStoreAndUserHeaders() { + givenBucket() + val bucket = "test-bucket" + val key = "hdrs.txt" + + // Build metadata with store headers and user metadata + val storeHeaders = mapOf( + HttpHeaders.CACHE_CONTROL to "max-age=3600", + HttpHeaders.CONTENT_LANGUAGE to "en" + ) + val userMeta = mapOf( + "foo" to "bar", + "answer" to "42" + ) + + val s3ObjectMetadata = s3ObjectMetadata( + key, + userMetadata = userMeta, + storeHeaders = storeHeaders, + ) + + whenever(objectService.verifyObjectExists(bucket, key, null)).thenReturn(s3ObjectMetadata) + + val resp = restTemplate.exchange( + "/$bucket/$key", + HttpMethod.GET, + HttpEntity(HttpHeaders()), + ByteArray::class.java + ) + + assertThat(resp.statusCode).isEqualTo(HttpStatus.OK) + // store headers propagated + assertThat(resp.headers.getFirst(HttpHeaders.CACHE_CONTROL)).isEqualTo("max-age=3600") + assertThat(resp.headers.getFirst(HttpHeaders.CONTENT_LANGUAGE)).isEqualTo("en") + // user metadata transformed to x-amz-meta-* + assertThat(resp.headers.getFirst("x-amz-meta-foo")).isEqualTo("bar") + assertThat(resp.headers.getFirst("x-amz-meta-answer")).isEqualTo("42") + } + + @Test + fun testGetObjectAttributes_EtagOnly_NoQuotes_AndVersionHeader() { + val bucket = "test-bucket" + val key = "attrs-etag.txt" + val testFile = File(UPLOAD_FILE_NAME) + + val versioningConfiguration = VersioningConfiguration( + VersioningConfiguration.MFADelete.DISABLED, + VersioningConfiguration.Status.ENABLED, + null + ) + val versioningBucket = bucketMetadata( + name = bucket, + versioningConfiguration = versioningConfiguration + ) + whenever(bucketService.verifyBucketExists(bucket)).thenReturn(versioningBucket) + + // note: S3ObjectMetadata normalizes etag to quoted; controller should strip quotes for attributes + val hex = DigestUtil.hexDigest(Files.newInputStream(testFile.toPath())) + val meta = s3ObjectMetadata(key, hex, versionId = "va1") + whenever(objectService.verifyObjectExists(bucket, key, null)).thenReturn(meta) + + val headers = HttpHeaders().apply { + this.accept = listOf(MediaType.APPLICATION_XML) + this[AwsHttpHeaders.X_AMZ_OBJECT_ATTRIBUTES] = "ETag" + } + val uri = UriComponentsBuilder + .fromUriString("/$bucket/$key") + .queryParam(AwsHttpParameters.ATTRIBUTES, "ignored") + .build() + .toString() + + val resp = restTemplate.exchange( + uri, + HttpMethod.GET, + HttpEntity(headers), + String::class.java + ) + + assertThat(resp.statusCode).isEqualTo(HttpStatus.OK) + // version header present + assertThat(resp.headers[AwsHttpHeaders.X_AMZ_VERSION_ID]).containsExactly("va1") + // ETag must be without quotes in XML body + assertThat(resp.body).contains("$hex") + // other fields not requested should not appear + assertThat(resp.body).doesNotContain("") + assertThat(resp.body).doesNotContain("") + } + + private fun givenBucket() { whenever(bucketService.getBucket(TEST_BUCKET_NAME)).thenReturn(TEST_BUCKET) whenever(bucketService.doesBucketExist(TEST_BUCKET_NAME)).thenReturn(true) whenever(bucketService.verifyBucketExists("test-bucket")).thenReturn(TEST_BUCKETMETADATA) @@ -605,36 +1447,56 @@ internal class ObjectControllerTest : BaseControllerTest() { 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")) - private val TEST_BUCKETMETADATA = BucketMetadata( - TEST_BUCKET_NAME, - Instant.now().toString(), - null, - null, - null, - null, - Paths.get("/tmp/foo/1"), - "us-east-1", - null, - null, - ) + private val TEST_BUCKETMETADATA = bucketMetadata() private const val UPLOAD_FILE_NAME = "src/test/resources/sampleFile.txt" fun s3ObjectEncrypted( - id: String, digest: String, encryption: String?, encryptionKey: String? + id: String, + digest: String = UUID.randomUUID().toString(), + encryption: String?, + encryptionKey: String? ): S3ObjectMetadata { return s3ObjectMetadata( - id, digest, encryption, encryptionKey, null, null + id, digest, encryption, encryptionKey, + ) + } + + fun bucketMetadata( + name: String = TEST_BUCKET_NAME, + creationDate: String = Instant.now().toString(), + path: Path = Paths.get("/tmp/foo/1"), + bucketRegion: String = "us-east-1", + versioningConfiguration: VersioningConfiguration? = null + ): BucketMetadata { + return BucketMetadata( + name, + creationDate, + versioningConfiguration, + null, + null, + null, + path, + bucketRegion, + null, + null, ) } @JvmOverloads fun s3ObjectMetadata( id: String, - digest: String, + digest: String = UUID.randomUUID().toString(), encryption: String? = null, encryptionKey: String? = null, retention: Retention? = null, - tags: List? = null + tags: List? = null, + legalHold: LegalHold? = null, + versionId: String? = null, + checksum: String? = null, + checksumType: ChecksumType? = ChecksumType.FULL_OBJECT, + checksumAlgorithm: ChecksumAlgorithm? = null, + userMetadata: Map? = null, + storeHeaders: Map? = null, ): S3ObjectMetadata { return S3ObjectMetadata( UUID.randomUUID(), @@ -645,20 +1507,20 @@ internal class ObjectControllerTest : BaseControllerTest() { "text/plain", 1L, Path.of(UPLOAD_FILE_NAME), - null, + userMetadata, tags, - null, + legalHold, retention, Owner.DEFAULT_OWNER, - null, + storeHeaders, encryptionHeaders(encryption, encryptionKey), + checksumAlgorithm, + checksum, null, null, - null, - null, - null, + versionId, false, - ChecksumType.FULL_OBJECT + checksumType ) } diff --git a/server/src/test/kotlin/com/adobe/testing/s3mock/dto/LocationConstraintTest.kt b/server/src/test/kotlin/com/adobe/testing/s3mock/dto/LocationConstraintTest.kt index 39a83e3be..fe11ebd68 100644 --- a/server/src/test/kotlin/com/adobe/testing/s3mock/dto/LocationConstraintTest.kt +++ b/server/src/test/kotlin/com/adobe/testing/s3mock/dto/LocationConstraintTest.kt @@ -30,4 +30,12 @@ internal class LocationConstraintTest { assertThat(iut).isNotNull() serializeAndAssert(iut, testInfo) } + + @Test + @Throws(IOException::class) + fun testSerialization_usEastOne(testInfo: TestInfo) { + val iut = LocationConstraint(Region.fromValue("us-east-1")) + assertThat(iut).isNotNull() + serializeAndAssert(iut, testInfo) + } } diff --git a/server/src/test/kotlin/com/adobe/testing/s3mock/service/BucketServiceTest.kt b/server/src/test/kotlin/com/adobe/testing/s3mock/service/BucketServiceTest.kt index 16aedc62a..910c9163d 100644 --- a/server/src/test/kotlin/com/adobe/testing/s3mock/service/BucketServiceTest.kt +++ b/server/src/test/kotlin/com/adobe/testing/s3mock/service/BucketServiceTest.kt @@ -17,29 +17,29 @@ package com.adobe.testing.s3mock.service import com.adobe.testing.s3mock.S3Exception +import com.adobe.testing.s3mock.dto.BucketLifecycleConfiguration +import com.adobe.testing.s3mock.dto.ObjectLockConfiguration +import com.adobe.testing.s3mock.dto.ObjectLockEnabled import com.adobe.testing.s3mock.dto.ObjectOwnership -import com.adobe.testing.s3mock.dto.S3Object import com.adobe.testing.s3mock.dto.VersioningConfiguration import com.adobe.testing.s3mock.dto.VersioningConfiguration.Status import com.adobe.testing.s3mock.store.BucketMetadata import com.adobe.testing.s3mock.store.MultipartStore +import com.adobe.testing.s3mock.store.S3ObjectMetadata import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy import org.junit.jupiter.api.Test -import org.junit.jupiter.params.ParameterizedTest -import org.junit.jupiter.params.provider.MethodSource import org.mockito.Mockito.verify 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.test.context.bean.override.mockito.MockitoBean import java.nio.file.Files import java.util.Date import java.util.UUID -import java.util.stream.Collectors @SpringBootTest(classes = [ServiceConfiguration::class], webEnvironment = SpringBootTest.WebEnvironment.NONE) -@MockBean(classes = [ObjectService::class, MultipartService::class, MultipartStore::class]) +@MockitoBean(types = [ObjectService::class, MultipartService::class, MultipartStore::class]) internal class BucketServiceTest : ServiceTestBase() { @Autowired private lateinit var iut: BucketService @@ -316,7 +316,352 @@ internal class BucketServiceTest : ServiceTestBase() { } } + @Test + fun testListObjectsV2_withDelimiterAndPrefix() { + val bucketName = "bucket" + val prefix = "b" + val delimiter = "/" + val encodingType = "url" + val startAfter: String? = null + val maxKeys = 100 + val continuationToken: String? = null + val fetchOwner = false + + // provide bucket with all keys; Service will collapse common prefixes + givenBucketWithContents(bucketName, prefix) + + val result = iut.listObjectsV2( + bucketName, + prefix, + delimiter, + encodingType, + startAfter, + maxKeys, + continuationToken, + fetchOwner + ) + + assertThat(result.name).isEqualTo(bucketName) + assertThat(result.prefix).isEqualTo(prefix) + assertThat(result.delimiter).isEqualTo(delimiter) + // With prefix "b" and delimiter "/", contents should include only key "b" and one common prefix "b/" + assertThat(result.contents).extracting { it.key }.containsExactly("b") + assertThat(result.commonPrefixes).extracting { it.prefix }.containsExactly("b/") + assertThat(result.isTruncated).isFalse() + } + + @Test + fun testListObjectsV2_paginationWithContinuationToken() { + val bucketName = "bucket" + val prefix: String? = null + val delimiter: String? = null + val encodingType = "url" + val startAfter: String? = null + val maxKeys = 5 // smaller than available keys to force pagination + val continuationToken: String? = null + val fetchOwner = false + + givenBucketWithContents(bucketName, prefix) + + // first page + val first = iut.listObjectsV2( + bucketName, + prefix, + delimiter, + encodingType, + startAfter, + maxKeys, + continuationToken, + fetchOwner + ) + + assertThat(first.isTruncated).isTrue() + assertThat(first.contents).hasSize(maxKeys) + assertThat(first.nextContinuationToken).isNotBlank() + + // second page using continuation token + val second = iut.listObjectsV2( + bucketName, + prefix, + delimiter, + encodingType, + startAfter, + maxKeys, + first.nextContinuationToken, + fetchOwner + ) + + val combined = first.contents + second.contents + assertThat(combined).hasSizeGreaterThanOrEqualTo(maxKeys * 2) + // total keys should not exceed all available + assertThat(combined.map { it.key }).doesNotHaveDuplicates() + // eventually we should reach not truncated after enough pages + // here we only assert the API sets token on first page and can fetch subsequent page + assertThat(second.encodingType).isEqualTo(encodingType) + } + + @Test + fun testListObjectsV2_withStartAfter() { + val bucketName = "bucket" + val prefix: String? = null + val delimiter: String? = null + val encodingType = "url" + val startAfter = "b/1" // skip everything up to this key lexicographically + val maxKeys = 100 + val continuationToken: String? = null + val fetchOwner = false + + givenBucketWithContents(bucketName, prefix) + + val result = iut.listObjectsV2( + bucketName, + prefix, + delimiter, + encodingType, + startAfter, + maxKeys, + continuationToken, + fetchOwner + ) + + // ensure no key before or equal to startAfter is present + assertThat(result.contents.map { it.key }.none { it <= startAfter }).isTrue() + assertThat(result.isTruncated).isFalse() + assertThat(result.encodingType).isEqualTo(encodingType) + assertThat(result.keyCount.toInt()).isEqualTo(result.contents.size) + } + + @Test + fun testListObjectsV2_emptyBucket() { + val bucketName = "empty-bucket" + val prefix: String? = null + val delimiter = "/" + val encodingType = "url" + val startAfter: String? = null + val maxKeys = 50 + val continuationToken: String? = null + val fetchOwner = false + + // Create bucket with no contents + givenBucketWithContents(bucketName, prefix, emptyList()) + + val result = iut.listObjectsV2( + bucketName, + prefix, + delimiter, + encodingType, + startAfter, + maxKeys, + continuationToken, + fetchOwner + ) + + assertThat(result.contents).isEmpty() + assertThat(result.commonPrefixes).isEmpty() + assertThat(result.isTruncated).isFalse() + assertThat(result.nextContinuationToken).isNull() + assertThat(result.keyCount.toInt()).isEqualTo(0) + } + + @Test + fun testVersioningConfiguration_getThrowsWhenAbsent_thenSetAndGet() { + val bucketName = "bucket" + val bucketMetadata = givenBucket(bucketName) + + // Initially absent should throw + assertThatThrownBy { iut.getVersioningConfiguration(bucketName) } + .isEqualTo(S3Exception.NOT_FOUND_BUCKET_VERSIONING_CONFIGURATION) + + // Set configuration + val cfg = VersioningConfiguration(null, Status.ENABLED, null) + iut.setVersioningConfiguration(bucketName, cfg) + + // After setting, BucketStore should have been invoked; we simulate by making metadata return the configuration + whenever(bucketStore.getBucketMetadata(bucketName)).thenReturn( + bucketMetadata( + bucketName, + bucketMetadata, + versioningConfiguration = cfg, + ) + ) + + val out = iut.getVersioningConfiguration(bucketName) + assertThat(out.status()).isEqualTo(Status.ENABLED) + } + + @Test + fun testObjectLockConfiguration_getThrowsWhenAbsent_thenSetAndGet() { + val bucketName = "bucket-lock" + val bucketMetadata = givenBucket(bucketName) + + // Absent -> throws + assertThatThrownBy { iut.getObjectLockConfiguration(bucketName) } + .isEqualTo(S3Exception.NOT_FOUND_BUCKET_OBJECT_LOCK) + + // Set configuration + val cfg = ObjectLockConfiguration( + ObjectLockEnabled.ENABLED, + null + ) + iut.setObjectLockConfiguration(bucketName, cfg) + + // Return metadata updated with configuration + whenever(bucketStore.getBucketMetadata(bucketName)).thenReturn( + bucketMetadata( + bucketName, + bucketMetadata, + cfg, + ) + ) + + val out = iut.getObjectLockConfiguration(bucketName) + assertThat(out.objectLockEnabled()).isEqualTo(ObjectLockEnabled.ENABLED) + } + + @Test + fun testBucketLifecycleConfiguration_setGetDelete() { + val bucketName = "bucket-lc" + val bucketMetadata = givenBucket(bucketName) + + // Absent -> throws + assertThatThrownBy { iut.getBucketLifecycleConfiguration(bucketName) } + .isEqualTo(S3Exception.NO_SUCH_LIFECYCLE_CONFIGURATION) + + // Set lifecycle configuration + val lc = BucketLifecycleConfiguration(emptyList()) + iut.setBucketLifecycleConfiguration(bucketName, lc) + + // Simulate store returning updated metadata + whenever(bucketStore.getBucketMetadata(bucketName)).thenReturn( + bucketMetadata( + bucketName, + bucketMetadata, + bucketLifecycleConfiguration = lc, + ) + ) + + val read = iut.getBucketLifecycleConfiguration(bucketName) + assertThat(read.rules()).isEmpty() + + // Delete configuration and ensure it's gone + iut.deleteBucketLifecycleConfiguration(bucketName) + + // After delete, simulate metadata without lifecycle configuration again + whenever(bucketStore.getBucketMetadata(bucketName)).thenReturn( + bucketMetadata(bucketName, bucketMetadata) + ) + + assertThatThrownBy { iut.getBucketLifecycleConfiguration(bucketName) } + .isEqualTo(S3Exception.NO_SUCH_LIFECYCLE_CONFIGURATION) + } + + @Test + fun testDeleteBucket_nonEmptyWithNonDeleteMarker_throws() { + val bucketName = "bucket-del" + val meta = givenBucket(bucketName) + val key = "k1" + val id = meta.addKey(key) + + // First call returns metadata with one object, second call also returns non-empty -> triggers exception + whenever(bucketStore.getBucketMetadata(bucketName)).thenReturn(meta, meta) + + // Object metadata without delete marker + whenever(objectStore.getS3ObjectMetadata(meta, id, null)).thenReturn(s3ObjectMetadata(id, key)) + + assertThatThrownBy { iut.deleteBucket(bucketName) } + .isInstanceOf(IllegalStateException::class.java) + .hasMessageContaining("Bucket is not empty: $bucketName") + } + + @Test + fun testDeleteBucket_onlyDeleteMarkersAreRemoved_andBucketDeleted() { + val bucketName = "bucket-del-markers" + val metaInitial = givenBucket(bucketName) + val key = "k1" + val id = metaInitial.addKey(key) + + // Metadata before deletion: contains one key + // After removing delete marker, metadata is empty + val metaAfter = bucketMetadata(bucketName, metaInitial) + + whenever(bucketStore.getBucketMetadata(bucketName)).thenReturn(metaInitial, metaAfter) + + // Return S3ObjectMetadata marked as delete marker + val dm = s3ObjectMetadata(id, key) + // mark it as a delete marker using helper + val dmMeta = S3ObjectMetadata.deleteMarker(dm, "v1") + + whenever(objectStore.getS3ObjectMetadata(metaInitial, id, null)).thenReturn(dmMeta) + + // bucketStore.deleteBucket should be called and return true + whenever(bucketStore.deleteBucket(bucketName)).thenReturn(true) + + val deleted = iut.deleteBucket(bucketName) + assertThat(deleted).isTrue() + // ensure we removed the key from the bucket + verify(bucketStore).removeFromBucket(key, bucketName) + verify(objectStore).doDeleteObject(metaInitial, id) + } + + @Test + fun testDeleteBucket_nonExistingBucket_throws() { + val bucketName = "no-such-bucket" + // Return null metadata to trigger the else-branch in service + whenever(bucketStore.getBucketMetadata(bucketName)).thenReturn(null) + assertThatThrownBy { iut.deleteBucket(bucketName) } + .isInstanceOf(IllegalStateException::class.java) + .hasMessageContaining("Requested Bucket does not exist: $bucketName") + } + + @Test + fun testListVersions_versioningDisabled_returnsCurrentVersionsOnly() { + val bucketName = "bucket-versions" + val prefix = "" + val delimiter = "" + val encodingType = "url" + val maxKeys = 100 + val keyMarker = "" + val versionIdMarker = "" + + givenBucketWithContents(bucketName, prefix) + + val out = iut.listVersions( + bucketName, + prefix, + delimiter, + encodingType, + maxKeys, + keyMarker, + versionIdMarker + ) + + // With versioning disabled, entries are mapped 1:1 without delete markers + assertThat(out.deleteMarkers()).isEmpty() + assertThat(out.objectVersions()).isNotEmpty() + } + companion object { private const val TEST_BUCKET_NAME = "test-bucket" + + private fun bucketMetadata( + bucketName: String, + bucketMetadata: BucketMetadata, + objectLockConfiguration: ObjectLockConfiguration? = bucketMetadata.objectLockConfiguration(), + bucketLifecycleConfiguration: BucketLifecycleConfiguration? = bucketMetadata.bucketLifecycleConfiguration(), + versioningConfiguration: VersioningConfiguration? = bucketMetadata.versioningConfiguration() + ): BucketMetadata { + return BucketMetadata( + bucketName, + bucketMetadata.creationDate(), + versioningConfiguration, + objectLockConfiguration, + bucketLifecycleConfiguration, + bucketMetadata.objectOwnership(), + bucketMetadata.path(), + bucketMetadata.bucketRegion(), + bucketMetadata.bucketInfo(), + bucketMetadata.locationInfo() + ) + } } } diff --git a/server/src/test/kotlin/com/adobe/testing/s3mock/service/MultipartServiceTest.kt b/server/src/test/kotlin/com/adobe/testing/s3mock/service/MultipartServiceTest.kt index c1d7dce5e..a5565aec5 100644 --- a/server/src/test/kotlin/com/adobe/testing/s3mock/service/MultipartServiceTest.kt +++ b/server/src/test/kotlin/com/adobe/testing/s3mock/service/MultipartServiceTest.kt @@ -28,14 +28,14 @@ import org.mockito.ArgumentMatchers 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.test.context.bean.override.mockito.MockitoBean import java.nio.file.Path import java.util.UUID @SpringBootTest(classes = [ServiceConfiguration::class], webEnvironment = SpringBootTest.WebEnvironment.NONE) -@MockBean(classes = [BucketService::class, ObjectService::class, ObjectStore::class]) +@MockitoBean(types = [BucketService::class, ObjectService::class, ObjectStore::class]) internal class MultipartServiceTest : ServiceTestBase() { - @MockBean + @MockitoBean private lateinit var multipartStore: MultipartStore @Autowired @@ -211,4 +211,78 @@ internal class MultipartServiceTest : ServiceTestBase() { val bucketName = "bucketName" iut.verifyMultipartUploadExists(bucketName, uploadId) } + + @Test + fun testVerifyPartNumberLimits_boundaryMax_success() { + val partNumber = "10000" + iut.verifyPartNumberLimits(partNumber) + } + + @Test + fun testVerifyPartNumberLimits_negativeNumberFailure() { + val partNumber = "-1" + assertThatThrownBy { iut.verifyPartNumberLimits(partNumber) } + .isEqualTo(S3Exception.INVALID_PART_NUMBER) + } + + @Test + fun testVerifyMultipartParts_withRequestedParts_keyNotFoundFailure() { + val bucketName = "bucketName" + val key = "missingKey" + val uploadId = UUID.randomUUID() + // create bucket but do not add the key to metadata so getID(key) returns null + givenBucket(bucketName) + + val requestedParts = emptyList() + + assertThatThrownBy { iut.verifyMultipartParts(bucketName, key, uploadId, requestedParts) } + .isEqualTo(S3Exception.INVALID_PART) + } + + @Test + fun testVerifyMultipartParts_withRequestedParts_missingUploadedPartFailure() { + val bucketName = "bucketName" + val key = "key" + val uploadId = UUID.randomUUID() + val bucketMetadata = givenBucket(bucketName) + val id = bucketMetadata.addKey(key) + // Only part 1 was uploaded + val uploadedParts = givenParts(1, MultipartService.MINIMUM_PART_SIZE) + whenever(multipartStore.getMultipartUploadParts(bucketMetadata, id, uploadId)).thenReturn(uploadedParts) + + // But request contains part 2 which does not exist in uploaded parts + val requestedParts = listOf( + CompletedPart( + null, + null, + null, + null, + null, + "\"nonexistent-etag\"", + 2 + ) + ) + + assertThatThrownBy { iut.verifyMultipartParts(bucketName, key, uploadId, requestedParts) } + .isEqualTo(S3Exception.INVALID_PART) + } + + @Test + fun testVerifyMultipartParts_idPath_noSuchUploadFailure() { + val bucketName = "bucketName" + val id = UUID.randomUUID() + val uploadId = UUID.randomUUID() + val bucketMetadata = givenBucket(bucketName) + + // Simulate missing upload -> MultipartService should translate to NO_SUCH_UPLOAD_MULTIPART + whenever( + multipartStore.getMultipartUpload( + ArgumentMatchers.eq(bucketMetadata), + ArgumentMatchers.eq(uploadId) + ) + ).thenThrow(IllegalArgumentException()) + + assertThatThrownBy { iut.verifyMultipartParts(bucketName, id, uploadId) } + .isEqualTo(S3Exception.NO_SUCH_UPLOAD_MULTIPART) + } } diff --git a/server/src/test/kotlin/com/adobe/testing/s3mock/service/ObjectServiceTest.kt b/server/src/test/kotlin/com/adobe/testing/s3mock/service/ObjectServiceTest.kt index 34ab54aab..248500df2 100644 --- a/server/src/test/kotlin/com/adobe/testing/s3mock/service/ObjectServiceTest.kt +++ b/server/src/test/kotlin/com/adobe/testing/s3mock/service/ObjectServiceTest.kt @@ -36,8 +36,8 @@ import org.mockito.ArgumentMatchers.isNull 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.http.HttpHeaders +import org.springframework.test.context.bean.override.mockito.MockitoBean import org.springframework.util.MultiValueMapAdapter import software.amazon.awssdk.checksums.DefaultChecksumAlgorithm import java.io.File @@ -49,7 +49,7 @@ import java.time.temporal.ChronoUnit import java.util.UUID @SpringBootTest(classes = [ServiceConfiguration::class], webEnvironment = SpringBootTest.WebEnvironment.NONE) -@MockBean(classes = [BucketService::class, MultipartService::class, MultipartStore::class]) +@MockitoBean(types = [BucketService::class, MultipartService::class, MultipartStore::class]) internal class ObjectServiceTest : ServiceTestBase() { @Autowired private lateinit var iut: ObjectService @@ -406,6 +406,80 @@ internal class ObjectServiceTest : ServiceTestBase() { return tempFile } + @Test + fun testVerifyObjectMatchingForCopy_notModifiedMapsToPreconditionFailed() { + val key = "key" + val metadata = s3ObjectMetadata(UUID.randomUUID(), key) + val ifModifiedSince = listOf(Instant.ofEpochMilli(metadata.lastModified()).plusSeconds(10)) + + assertThatThrownBy { + iut.verifyObjectMatchingForCopy(null, null, ifModifiedSince, null, metadata) + }.isEqualTo(S3Exception.PRECONDITION_FAILED) + } + + @Test + fun testVerifyObjectMatchingForCopy_preconditionFailedPassThrough() { + val key = "key" + val metadata = s3ObjectMetadata(UUID.randomUUID(), key) + val match = listOf("\"nonematch\"") + + assertThatThrownBy { + iut.verifyObjectMatchingForCopy(match, null, null, null, metadata) + }.isEqualTo(S3Exception.PRECONDITION_FAILED) + } + + @Test + fun testVerifyObjectMatching_byName_notModifiedMapsToPreconditionFailed() { + val bucketName = "bucket" + val key = "key" + givenBucketWithContents(bucketName, "", listOf(givenS3Object(key))) + + assertThatThrownBy { + iut.verifyObjectMatching(bucketName, key, null, listOf("\"etag\"")) + }.isEqualTo(S3Exception.PRECONDITION_FAILED) + } + + @Test + fun testVerifyObjectMatching_nullMetadataWithMatch_throwsNoSuchKey() { + assertThatThrownBy { + iut.verifyObjectMatching(listOf("\"anything\""), null, null, null, null) + }.isEqualTo(S3Exception.NO_SUCH_KEY) + } + + @Test + fun testVerifyObjectMatching_matchLastModified_success() { + val metadata = s3ObjectMetadata(UUID.randomUUID(), "key") + val lastModified = Instant.ofEpochMilli(metadata.lastModified()).truncatedTo(ChronoUnit.SECONDS) + + iut.verifyObjectMatching(listOf("\"etag\""), listOf(lastModified), null, metadata) + } + + @Test + fun testVerifyObjectMatching_matchLastModified_failure() { + val metadata = s3ObjectMetadata(UUID.randomUUID(), "key") + val lastModifiedWrong = Instant.ofEpochMilli(metadata.lastModified()).minusSeconds(10).truncatedTo(ChronoUnit.SECONDS) + + assertThatThrownBy { + iut.verifyObjectMatching(listOf("\"etag\""), listOf(lastModifiedWrong), null, metadata) + }.isEqualTo(S3Exception.PRECONDITION_FAILED) + } + + @Test + fun testVerifyMd5_pathIOException_badRequestContent() { + val bogus = Path.of("this/does/not/exist-" + UUID.randomUUID()) + assertThatThrownBy { iut.verifyMd5(bogus, "abc") }.isEqualTo(S3Exception.BAD_REQUEST_CONTENT) + } + + @Test + fun testDeleteObject_missingKey_returnsFalse() { + val bucketName = "bucket" + val key = "missing" + givenBucket(bucketName) + + val deleted = iut.deleteObject(bucketName, key, null) + assertThat(deleted).isFalse() + } + companion object { private const val TEST_FILE_PATH = "src/test/resources/sampleFile.txt" } diff --git a/server/src/test/kotlin/com/adobe/testing/s3mock/service/ServiceTestBase.kt b/server/src/test/kotlin/com/adobe/testing/s3mock/service/ServiceTestBase.kt index 98bc25d89..3350d55e3 100644 --- a/server/src/test/kotlin/com/adobe/testing/s3mock/service/ServiceTestBase.kt +++ b/server/src/test/kotlin/com/adobe/testing/s3mock/service/ServiceTestBase.kt @@ -33,7 +33,7 @@ import org.junit.jupiter.api.Test import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.MethodSource import org.mockito.kotlin.whenever -import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.test.context.bean.override.mockito.MockitoBean import java.nio.file.Files import java.nio.file.Path import java.time.Instant @@ -42,10 +42,10 @@ import java.util.UUID import java.util.stream.Collectors internal abstract class ServiceTestBase { - @MockBean + @MockitoBean protected lateinit var bucketStore: BucketStore - @MockBean + @MockitoBean protected lateinit var objectStore: ObjectStore @ParameterizedTest diff --git a/server/src/test/kotlin/com/adobe/testing/s3mock/store/BucketStoreTest.kt b/server/src/test/kotlin/com/adobe/testing/s3mock/store/BucketStoreTest.kt index ebeff873a..cbaaf7078 100644 --- a/server/src/test/kotlin/com/adobe/testing/s3mock/store/BucketStoreTest.kt +++ b/server/src/test/kotlin/com/adobe/testing/s3mock/store/BucketStoreTest.kt @@ -30,11 +30,11 @@ import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureWebMvc import org.springframework.boot.test.context.SpringBootTest -import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.test.context.bean.override.mockito.MockitoBean @AutoConfigureWebMvc @AutoConfigureMockMvc -@MockBean(classes = [KmsKeyStore::class, ObjectStore::class, MultipartStore::class]) +@MockitoBean(types = [KmsKeyStore::class, ObjectStore::class, MultipartStore::class]) @SpringBootTest(classes = [StoreConfiguration::class], webEnvironment = SpringBootTest.WebEnvironment.NONE) internal class BucketStoreTest : StoreTestBase() { @Autowired diff --git a/server/src/test/kotlin/com/adobe/testing/s3mock/store/KmsKeyStoreTest.kt b/server/src/test/kotlin/com/adobe/testing/s3mock/store/KmsKeyStoreTest.kt index c750a0cd6..d77be63c8 100644 --- a/server/src/test/kotlin/com/adobe/testing/s3mock/store/KmsKeyStoreTest.kt +++ b/server/src/test/kotlin/com/adobe/testing/s3mock/store/KmsKeyStoreTest.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. @@ -19,9 +19,9 @@ 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.test.context.bean.override.mockito.MockitoBean -@MockBean(classes = [BucketStore::class, ObjectStore::class, MultipartStore::class]) +@MockitoBean(types = [BucketStore::class, ObjectStore::class, MultipartStore::class]) @SpringBootTest(classes = [StoreConfiguration::class], webEnvironment = SpringBootTest.WebEnvironment.NONE) internal class KmsKeyStoreTest { @Autowired diff --git a/server/src/test/kotlin/com/adobe/testing/s3mock/store/MultipartStoreTest.kt b/server/src/test/kotlin/com/adobe/testing/s3mock/store/MultipartStoreTest.kt index 31d7b6bad..e91647757 100644 --- a/server/src/test/kotlin/com/adobe/testing/s3mock/store/MultipartStoreTest.kt +++ b/server/src/test/kotlin/com/adobe/testing/s3mock/store/MultipartStoreTest.kt @@ -19,7 +19,6 @@ import com.adobe.testing.s3mock.S3Exception import com.adobe.testing.s3mock.dto.ChecksumAlgorithm import com.adobe.testing.s3mock.dto.ChecksumType import com.adobe.testing.s3mock.dto.CompletedPart -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 @@ -38,9 +37,9 @@ import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureWebMvc import org.springframework.boot.test.context.SpringBootTest -import org.springframework.boot.test.mock.mockito.MockBean import org.springframework.http.HttpRange import org.springframework.http.MediaType +import org.springframework.test.context.bean.override.mockito.MockitoBean import software.amazon.awssdk.checksums.DefaultChecksumAlgorithm import java.io.ByteArrayInputStream import java.io.File @@ -54,7 +53,7 @@ import java.util.UUID @AutoConfigureWebMvc @AutoConfigureMockMvc -@MockBean(classes = [KmsKeyStore::class, BucketStore::class]) +@MockitoBean(types = [KmsKeyStore::class, BucketStore::class]) @SpringBootTest(classes = [StoreConfiguration::class], webEnvironment = SpringBootTest.WebEnvironment.NONE) @Execution(ExecutionMode.SAME_THREAD) internal class MultipartStoreTest : StoreTestBase() { diff --git a/server/src/test/kotlin/com/adobe/testing/s3mock/store/ObjectStoreTest.kt b/server/src/test/kotlin/com/adobe/testing/s3mock/store/ObjectStoreTest.kt index a86af6dcd..80c6ef010 100644 --- a/server/src/test/kotlin/com/adobe/testing/s3mock/store/ObjectStoreTest.kt +++ b/server/src/test/kotlin/com/adobe/testing/s3mock/store/ObjectStoreTest.kt @@ -37,8 +37,8 @@ import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureWebMvc import org.springframework.boot.test.context.SpringBootTest -import org.springframework.boot.test.mock.mockito.MockBean import org.springframework.http.HttpHeaders +import org.springframework.test.context.bean.override.mockito.MockitoBean import java.io.File import java.nio.file.Files import java.time.Instant @@ -48,7 +48,7 @@ import java.util.UUID @AutoConfigureWebMvc @AutoConfigureMockMvc -@MockBean(classes = [KmsKeyStore::class, BucketStore::class, MultipartStore::class]) +@MockitoBean(types = [KmsKeyStore::class, BucketStore::class, MultipartStore::class]) @SpringBootTest(classes = [StoreConfiguration::class], webEnvironment = SpringBootTest.WebEnvironment.NONE) @Execution(ExecutionMode.SAME_THREAD) internal class ObjectStoreTest : StoreTestBase() { diff --git a/server/src/test/kotlin/com/adobe/testing/s3mock/store/StoresWithExistingFileRootTest.kt b/server/src/test/kotlin/com/adobe/testing/s3mock/store/StoresWithExistingFileRootTest.kt index 118cb11ef..bd7ab98f1 100644 --- a/server/src/test/kotlin/com/adobe/testing/s3mock/store/StoresWithExistingFileRootTest.kt +++ b/server/src/test/kotlin/com/adobe/testing/s3mock/store/StoresWithExistingFileRootTest.kt @@ -28,13 +28,13 @@ 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.context.TestConfiguration -import org.springframework.boot.test.mock.mockito.MockBean import org.springframework.context.annotation.Bean +import org.springframework.test.context.bean.override.mockito.MockitoBean import software.amazon.awssdk.regions.Region import java.io.File import java.util.UUID -@MockBean(classes = [KmsKeyStore::class]) +@MockitoBean(types = [KmsKeyStore::class]) @SpringBootTest( classes = [StoreConfiguration::class, TestConfig::class], webEnvironment = SpringBootTest.WebEnvironment.NONE ) diff --git a/server/src/test/resources/com/adobe/testing/s3mock/dto/LocationConstraintTest_testSerialization_usEastOne.xml b/server/src/test/resources/com/adobe/testing/s3mock/dto/LocationConstraintTest_testSerialization_usEastOne.xml new file mode 100644 index 000000000..87be4b7d2 --- /dev/null +++ b/server/src/test/resources/com/adobe/testing/s3mock/dto/LocationConstraintTest_testSerialization_usEastOne.xml @@ -0,0 +1,19 @@ + + +null