From 9b684aedeab3017a2ec5cedf1580f3c9b5de9cb2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Feb 2026 22:42:59 +0000 Subject: [PATCH 1/3] Initial plan From cfad94d83661fbe798ca80dd5f9e9c9bd49a0fa1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Feb 2026 22:55:09 +0000 Subject: [PATCH 2/3] feat: implement bucket tagging operations (GetBucketTagging, PutBucketTagging, DeleteBucketTagging) Co-authored-by: afranken <763000+afranken@users.noreply.github.com> --- CHANGELOG.md | 1 + README.md | 6 +- .../testing/s3mock/its/BucketTaggingIT.kt | 116 ++++++++++++++++++ .../com/adobe/testing/s3mock/S3Exception.kt | 4 + .../s3mock/controller/BucketController.kt | 84 ++++++++++++- .../testing/s3mock/service/BucketService.kt | 15 +++ .../testing/s3mock/store/BucketMetadata.kt | 7 ++ .../adobe/testing/s3mock/store/BucketStore.kt | 10 ++ .../s3mock/controller/BucketControllerTest.kt | 71 +++++++++++ .../s3mock/service/BucketServiceTest.kt | 40 +++++- 10 files changed, 346 insertions(+), 8 deletions(-) create mode 100644 integration-tests/src/test/kotlin/com/adobe/testing/s3mock/its/BucketTaggingIT.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index a15194216..4ab873b4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -145,6 +145,7 @@ Version 5.x is JDK17 LTS bytecode compatible, with Docker and JUnit / direct Jav ## 5.0.0 * Features and fixes + * Implement bucket tagging operations: GetBucketTagging, PutBucketTagging, DeleteBucketTagging. * Breaking change (file system): Remove "DisplayName" from Owner. (fixes #2738) * AWS APIs stopped returning "DisplayName" in November 2025. * This is unfortunately a breaking change for clients starting S3Mock on existing file systems. diff --git a/README.md b/README.md index c5a3328ee..52ea75f5f 100755 --- a/README.md +++ b/README.md @@ -118,7 +118,7 @@ See the [complete operations table](https://docs.aws.amazon.com/AmazonS3/latest/ | [DeleteBucketOwnershipControls](https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteBucketOwnershipControls.html) | :x: | | | [DeleteBucketPolicy](https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteBucketPolicy.html) | :x: | | | [DeleteBucketReplication](https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteBucketReplication.html) | :x: | | -| [DeleteBucketTagging](https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteBucketTagging.html) | :x: | | +| [DeleteBucketTagging](https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteBucketTagging.html) | :white_check_mark: | | | [DeleteBucketWebsite](https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteBucketWebsite.html) | :x: | | | [DeleteObject](https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteObject.html) | :white_check_mark: | | | [DeleteObjects](https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteObjects.html) | :white_check_mark: | | @@ -143,7 +143,7 @@ See the [complete operations table](https://docs.aws.amazon.com/AmazonS3/latest/ | [GetBucketPolicyStatus](https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetBucketPolicyStatus.html) | :x: | | | [GetBucketReplication](https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetBucketReplication.html) | :x: | | | [GetBucketRequestPayment](https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetBucketRequestPayment.html) | :x: | | -| [GetBucketTagging](https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetBucketTagging.html) | :x: | | +| [GetBucketTagging](https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetBucketTagging.html) | :white_check_mark: | | | [GetBucketVersioning](https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetBucketVersioning.html) | :white_check_mark: | | | [GetBucketWebsite](https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetBucketWebsite.html) | :x: | | | [GetObject](https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetObject.html) | :white_check_mark: | | @@ -185,7 +185,7 @@ See the [complete operations table](https://docs.aws.amazon.com/AmazonS3/latest/ | [PutBucketPolicy](https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutBucketPolicy.html) | :x: | | | [PutBucketReplication](https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutBucketReplication.html) | :x: | | | [PutBucketRequestPayment](https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutBucketRequestPayment.html) | :x: | | -| [PutBucketTagging](https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutBucketTagging.html) | :x: | | +| [PutBucketTagging](https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutBucketTagging.html) | :white_check_mark: | | | [PutBucketVersioning](https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutBucketVersioning.html) | :white_check_mark: | | | [PutBucketWebsite](https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutBucketWebsite.html) | :x: | | | [PutObject](https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutObject.html) | :white_check_mark: | | diff --git a/integration-tests/src/test/kotlin/com/adobe/testing/s3mock/its/BucketTaggingIT.kt b/integration-tests/src/test/kotlin/com/adobe/testing/s3mock/its/BucketTaggingIT.kt new file mode 100644 index 000000000..9937c8c2f --- /dev/null +++ b/integration-tests/src/test/kotlin/com/adobe/testing/s3mock/its/BucketTaggingIT.kt @@ -0,0 +1,116 @@ +/* + * Copyright 2017-2026 Adobe. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.adobe.testing.s3mock.its + +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.assertj.core.api.InstanceOfAssertFactories +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInfo +import software.amazon.awssdk.awscore.exception.AwsErrorDetails +import software.amazon.awssdk.awscore.exception.AwsServiceException +import software.amazon.awssdk.services.s3.S3Client +import software.amazon.awssdk.services.s3.model.Tag +import software.amazon.awssdk.services.s3.model.Tagging + +internal class BucketTaggingIT : S3TestBase() { + private val s3Client: S3Client = createS3Client() + + @Test + @S3VerifiedSuccess(year = 2026) + fun `get bucket tagging returns error if not set`(testInfo: TestInfo) { + val bucketName = givenBucket(testInfo) + + assertThatThrownBy { + s3Client.getBucketTagging { it.bucket(bucketName) } + }.isInstanceOf(AwsServiceException::class.java) + .hasMessageContaining("Service: S3, Status Code: 404") + .asInstanceOf(InstanceOfAssertFactories.type(AwsServiceException::class.java)) + .extracting(AwsServiceException::awsErrorDetails) + .extracting(AwsErrorDetails::errorCode) + .isEqualTo("NoSuchTagSet") + } + + @Test + @S3VerifiedSuccess(year = 2026) + fun `put and get bucket tagging succeeds`(testInfo: TestInfo) { + val bucketName = givenBucket(testInfo) + val tag1 = tag("env" to "production") + val tag2 = tag("team" to "platform") + + s3Client.putBucketTagging { + it.bucket(bucketName) + it.tagging(Tagging.builder().tagSet(tag1, tag2).build()) + } + + assertThat( + s3Client.getBucketTagging { it.bucket(bucketName) }.tagSet() + ).contains(tag1, tag2) + } + + @Test + @S3VerifiedSuccess(year = 2026) + fun `put get and delete bucket tagging succeeds`(testInfo: TestInfo) { + val bucketName = givenBucket(testInfo) + val tag1 = tag("env" to "staging") + val tag2 = tag("owner" to "team-a") + + s3Client.putBucketTagging { + it.bucket(bucketName) + it.tagging(Tagging.builder().tagSet(tag1, tag2).build()) + } + + assertThat( + s3Client.getBucketTagging { it.bucket(bucketName) }.tagSet() + ).contains(tag1, tag2) + + s3Client.deleteBucketTagging { it.bucket(bucketName) } + + assertThatThrownBy { + s3Client.getBucketTagging { it.bucket(bucketName) } + }.isInstanceOf(AwsServiceException::class.java) + .hasMessageContaining("Service: S3, Status Code: 404") + .asInstanceOf(InstanceOfAssertFactories.type(AwsServiceException::class.java)) + .extracting(AwsServiceException::awsErrorDetails) + .extracting(AwsErrorDetails::errorCode) + .isEqualTo("NoSuchTagSet") + } + + @Test + @S3VerifiedSuccess(year = 2026) + fun `put bucket tagging replaces existing tags`(testInfo: TestInfo) { + val bucketName = givenBucket(testInfo) + val tag1 = tag("env" to "dev") + + s3Client.putBucketTagging { + it.bucket(bucketName) + it.tagging(Tagging.builder().tagSet(tag1).build()) + } + + val tag2 = tag("env" to "prod") + s3Client.putBucketTagging { + it.bucket(bucketName) + it.tagging(Tagging.builder().tagSet(tag2).build()) + } + + val result = s3Client.getBucketTagging { it.bucket(bucketName) }.tagSet() + assertThat(result).containsExactly(tag2) + assertThat(result).doesNotContain(tag1) + } + + private fun tag(pair: Pair): Tag = + Tag.builder().key(pair.first).value(pair.second).build() +} diff --git a/server/src/main/kotlin/com/adobe/testing/s3mock/S3Exception.kt b/server/src/main/kotlin/com/adobe/testing/s3mock/S3Exception.kt index 4a6c051c5..ab151d221 100644 --- a/server/src/main/kotlin/com/adobe/testing/s3mock/S3Exception.kt +++ b/server/src/main/kotlin/com/adobe/testing/s3mock/S3Exception.kt @@ -93,6 +93,10 @@ class S3Exception HttpStatus.NOT_FOUND.value(), "NoSuchLifecycleConfiguration", "The lifecycle configuration does not exist." ) + val NO_SUCH_TAG_SET: S3Exception = S3Exception( + HttpStatus.NOT_FOUND.value(), "NoSuchTagSet", + "There is no tag set associated with the bucket." + ) val NO_SUCH_KEY: S3Exception = S3Exception(HttpStatus.NOT_FOUND.value(), "NoSuchKey", "The specified key does not exist.") val NO_SUCH_VERSION: S3Exception = S3Exception( diff --git a/server/src/main/kotlin/com/adobe/testing/s3mock/controller/BucketController.kt b/server/src/main/kotlin/com/adobe/testing/s3mock/controller/BucketController.kt index 47362a1ea..015448ccf 100644 --- a/server/src/main/kotlin/com/adobe/testing/s3mock/controller/BucketController.kt +++ b/server/src/main/kotlin/com/adobe/testing/s3mock/controller/BucketController.kt @@ -26,6 +26,9 @@ import com.adobe.testing.s3mock.dto.LocationConstraint import com.adobe.testing.s3mock.dto.ObjectLockConfiguration import com.adobe.testing.s3mock.dto.ObjectOwnership import com.adobe.testing.s3mock.dto.Region +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.util.AwsHttpHeaders.X_AMZ_BUCKET_OBJECT_LOCK_ENABLED @@ -45,11 +48,13 @@ import com.adobe.testing.s3mock.util.AwsHttpParameters.NOT_LIFECYCLE import com.adobe.testing.s3mock.util.AwsHttpParameters.NOT_LIST_TYPE import com.adobe.testing.s3mock.util.AwsHttpParameters.NOT_LOCATION import com.adobe.testing.s3mock.util.AwsHttpParameters.NOT_OBJECT_LOCK +import com.adobe.testing.s3mock.util.AwsHttpParameters.NOT_TAGGING import com.adobe.testing.s3mock.util.AwsHttpParameters.NOT_UPLOADS import com.adobe.testing.s3mock.util.AwsHttpParameters.NOT_VERSIONING import com.adobe.testing.s3mock.util.AwsHttpParameters.NOT_VERSIONS import com.adobe.testing.s3mock.util.AwsHttpParameters.OBJECT_LOCK import com.adobe.testing.s3mock.util.AwsHttpParameters.START_AFTER +import com.adobe.testing.s3mock.util.AwsHttpParameters.TAGGING import com.adobe.testing.s3mock.util.AwsHttpParameters.VERSIONING import com.adobe.testing.s3mock.util.AwsHttpParameters.VERSIONS import com.adobe.testing.s3mock.util.AwsHttpParameters.VERSION_ID_MARKER @@ -119,7 +124,8 @@ class BucketController(private val bucketService: BucketService) { params = [ NOT_OBJECT_LOCK, NOT_LIFECYCLE, - NOT_VERSIONING + NOT_VERSIONING, + NOT_TAGGING ] ) @S3Verified(year = 2025) @@ -186,7 +192,8 @@ class BucketController(private val bucketService: BucketService) { // AWS SDK V1 pattern "/{bucketName:.+}/" ], params = [ - NOT_LIFECYCLE + NOT_LIFECYCLE, + NOT_TAGGING ] ) @S3Verified(year = 2025) @@ -365,6 +372,76 @@ class BucketController(private val bucketService: BucketService) { return ResponseEntity.noContent().build() } + /** + * [API Reference](https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetBucketTagging.html). + */ + @GetMapping( + value = [ + // AWS SDK V2 pattern + "/{bucketName:.+}", + // AWS SDK V1 pattern + "/{bucketName:.+}/" + ], + params = [ + TAGGING, + NOT_LIST_TYPE + ], + produces = [ + MediaType.APPLICATION_XML_VALUE + ] + ) + @S3Verified(year = 2026) + fun getBucketTagging(@PathVariable bucketName: String): ResponseEntity { + bucketService.verifyBucketExists(bucketName) + val tags = bucketService.getBucketTagging(bucketName) + return ResponseEntity.ok(Tagging(TagSet(tags))) + } + + /** + * [API Reference](https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutBucketTagging.html). + */ + @PutMapping( + value = [ + // AWS SDK V2 pattern + "/{bucketName:.+}", + // AWS SDK V1 pattern + "/{bucketName:.+}/" + ], + params = [ + TAGGING + ] + ) + @S3Verified(year = 2026) + fun putBucketTagging( + @PathVariable bucketName: String, + @RequestBody tagging: Tagging + ): ResponseEntity { + bucketService.verifyBucketExists(bucketName) + bucketService.setBucketTagging(bucketName, tagging.tagSet.tags) + return ResponseEntity.ok().build() + } + + /** + * [API Reference](https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteBucketTagging.html). + */ + @DeleteMapping( + value = [ + // AWS SDK V2 pattern + "/{bucketName:.+}", + // AWS SDK V1 pattern + "/{bucketName:.+}/" + ], + params = [ + TAGGING + ] + ) + @S3Verified(year = 2026) + fun deleteBucketTagging(@PathVariable bucketName: String): ResponseEntity { + bucketService.verifyBucketExists(bucketName) + bucketService.deleteBucketTagging(bucketName) + return ResponseEntity.noContent().build() + } + /** * [API Reference](https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetBucketLocation.html). */ @@ -406,7 +483,8 @@ class BucketController(private val bucketService: BucketService) { NOT_LIFECYCLE, NOT_LOCATION, NOT_VERSIONS, - NOT_VERSIONING + NOT_VERSIONING, + NOT_TAGGING ], produces = [ MediaType.APPLICATION_XML_VALUE diff --git a/server/src/main/kotlin/com/adobe/testing/s3mock/service/BucketService.kt b/server/src/main/kotlin/com/adobe/testing/s3mock/service/BucketService.kt index 25b09aa7c..1872af40a 100644 --- a/server/src/main/kotlin/com/adobe/testing/s3mock/service/BucketService.kt +++ b/server/src/main/kotlin/com/adobe/testing/s3mock/service/BucketService.kt @@ -34,6 +34,7 @@ import com.adobe.testing.s3mock.dto.Owner import com.adobe.testing.s3mock.dto.Prefix import com.adobe.testing.s3mock.dto.Region import com.adobe.testing.s3mock.dto.S3Object +import com.adobe.testing.s3mock.dto.Tag import com.adobe.testing.s3mock.dto.VersioningConfiguration import com.adobe.testing.s3mock.store.BucketMetadata import com.adobe.testing.s3mock.store.BucketStore @@ -181,6 +182,20 @@ open class BucketService( return bucketMetadata.bucketLifecycleConfiguration ?: throw S3Exception.NO_SUCH_LIFECYCLE_CONFIGURATION } + fun setBucketTagging(bucketName: String, tags: List?) { + val bucketMetadata = bucketStore.getBucketMetadata(bucketName) + bucketStore.storeBucketTagging(bucketMetadata, tags) + } + + fun deleteBucketTagging(bucketName: String) { + setBucketTagging(bucketName, null) + } + + fun getBucketTagging(bucketName: String): List { + val bucketMetadata = bucketStore.getBucketMetadata(bucketName) + return bucketMetadata.tags?.takeIf { it.isNotEmpty() } ?: throw S3Exception.NO_SUCH_TAG_SET + } + fun getS3Objects(bucketName: String, prefix: String?): List { val bucketMetadata = bucketStore.getBucketMetadata(bucketName) return bucketStore.lookupIdsInBucket(prefix, bucketName) diff --git a/server/src/main/kotlin/com/adobe/testing/s3mock/store/BucketMetadata.kt b/server/src/main/kotlin/com/adobe/testing/s3mock/store/BucketMetadata.kt index b81275a5f..ff38944ef 100644 --- a/server/src/main/kotlin/com/adobe/testing/s3mock/store/BucketMetadata.kt +++ b/server/src/main/kotlin/com/adobe/testing/s3mock/store/BucketMetadata.kt @@ -21,8 +21,10 @@ import com.adobe.testing.s3mock.dto.BucketLifecycleConfiguration import com.adobe.testing.s3mock.dto.LocationInfo import com.adobe.testing.s3mock.dto.ObjectLockConfiguration import com.adobe.testing.s3mock.dto.ObjectOwnership +import com.adobe.testing.s3mock.dto.Tag import com.adobe.testing.s3mock.dto.VersioningConfiguration import com.fasterxml.jackson.annotation.JsonIgnore +import com.fasterxml.jackson.annotation.JsonInclude import com.fasterxml.jackson.annotation.JsonProperty import java.nio.file.Path import java.util.UUID @@ -41,6 +43,8 @@ data class BucketMetadata( val bucketRegion: String, val bucketInfo: BucketInfo?, val locationInfo: LocationInfo?, + @JsonInclude(JsonInclude.Include.NON_NULL) + val tags: List? = null, @param:JsonProperty("objects") private val _objects: MutableMap = mutableMapOf() ) { @@ -69,6 +73,9 @@ data class BucketMetadata( fun withBucketLifecycleConfiguration(bucketLifecycleConfiguration: BucketLifecycleConfiguration?): BucketMetadata = this.copy(bucketLifecycleConfiguration = bucketLifecycleConfiguration) + fun withTags(tags: List?): BucketMetadata = + this.copy(tags = tags) + @get:JsonIgnore val isVersioningEnabled: Boolean get() = this.versioningConfiguration?.status == VersioningConfiguration.Status.ENABLED diff --git a/server/src/main/kotlin/com/adobe/testing/s3mock/store/BucketStore.kt b/server/src/main/kotlin/com/adobe/testing/s3mock/store/BucketStore.kt index 6a3b6380d..4ce7ede4a 100644 --- a/server/src/main/kotlin/com/adobe/testing/s3mock/store/BucketStore.kt +++ b/server/src/main/kotlin/com/adobe/testing/s3mock/store/BucketStore.kt @@ -22,6 +22,7 @@ import com.adobe.testing.s3mock.dto.LocationInfo import com.adobe.testing.s3mock.dto.ObjectLockConfiguration import com.adobe.testing.s3mock.dto.ObjectLockEnabled.ENABLED import com.adobe.testing.s3mock.dto.ObjectOwnership +import com.adobe.testing.s3mock.dto.Tag import com.adobe.testing.s3mock.dto.VersioningConfiguration import org.slf4j.Logger import org.slf4j.LoggerFactory @@ -195,6 +196,15 @@ open class BucketStore( } } + fun storeBucketTagging( + metadata: BucketMetadata, + tags: List? + ) { + synchronized(lockStore[metadata.name]!!) { + writeToDisk(metadata.withTags(tags)) + } + } + fun isBucketEmpty(bucketName: String): Boolean { check(doesBucketExist(bucketName)) { "Requested Bucket does not exist: $bucketName" } return getBucketMetadata(bucketName).objects.isEmpty() diff --git a/server/src/test/kotlin/com/adobe/testing/s3mock/controller/BucketControllerTest.kt b/server/src/test/kotlin/com/adobe/testing/s3mock/controller/BucketControllerTest.kt index 6e7e23da4..7087ad6fa 100644 --- a/server/src/test/kotlin/com/adobe/testing/s3mock/controller/BucketControllerTest.kt +++ b/server/src/test/kotlin/com/adobe/testing/s3mock/controller/BucketControllerTest.kt @@ -44,6 +44,9 @@ import com.adobe.testing.s3mock.dto.ObjectOwnership.BUCKET_OWNER_ENFORCED import com.adobe.testing.s3mock.dto.Region import com.adobe.testing.s3mock.dto.S3Object 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.Transition import com.adobe.testing.s3mock.dto.VersioningConfiguration import com.adobe.testing.s3mock.service.BucketService @@ -673,6 +676,74 @@ internal class BucketControllerTest : BaseControllerTest() { verify(bucketService).deleteBucketLifecycleConfiguration(TEST_BUCKET_NAME) } + @Test + @Throws(Exception::class) + fun testPutBucketTagging_Ok() { + givenBucket() + + val tags = listOf(Tag("key1", "value1"), Tag("key2", "value2")) + val tagging = Tagging(TagSet(tags)) + + val uri = UriComponentsBuilder + .fromUriString("/test-bucket") + .queryParam(AwsHttpParameters.TAGGING, "ignored") + .build() + .toString() + + mockMvc.perform( + put(uri) + .accept(MediaType.APPLICATION_XML) + .contentType(MediaType.APPLICATION_XML) + .content(MAPPER.writeValueAsString(tagging)) + ) + .andExpect(status().isOk) + + verify(bucketService).setBucketTagging(TEST_BUCKET_NAME, tags) + } + + @Test + @Throws(Exception::class) + fun testGetBucketTagging_Ok() { + givenBucket() + + val tags = listOf(Tag("key1", "value1"), Tag("key2", "value2")) + whenever(bucketService.getBucketTagging(TEST_BUCKET_NAME)).thenReturn(tags) + + val uri = UriComponentsBuilder + .fromUriString("/test-bucket") + .queryParam(AwsHttpParameters.TAGGING, "ignored") + .build() + .toString() + + mockMvc.perform( + get(uri) + .accept(MediaType.APPLICATION_XML) + .contentType(MediaType.APPLICATION_XML) + ) + .andExpect(status().isOk) + .andExpect(content().string(MAPPER.writeValueAsString(Tagging(TagSet(tags))))) + } + + @Test + @Throws(Exception::class) + fun testDeleteBucketTagging_NoContent() { + givenBucket() + + val uri = UriComponentsBuilder + .fromUriString("/test-bucket") + .queryParam(AwsHttpParameters.TAGGING, "ignored") + .build() + .toString() + + mockMvc.perform( + delete(uri) + .accept(MediaType.APPLICATION_XML) + .contentType(MediaType.APPLICATION_XML) + ) + .andExpect(status().isNoContent) + verify(bucketService).deleteBucketTagging(TEST_BUCKET_NAME) + } + @Test @Throws(Exception::class) fun testGetBucketLocation_Ok() { 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 62dd79335..5f1071951 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 @@ -21,6 +21,7 @@ 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.Tag import com.adobe.testing.s3mock.dto.VersioningConfiguration import com.adobe.testing.s3mock.dto.VersioningConfiguration.Status import com.adobe.testing.s3mock.store.BucketMetadata @@ -593,6 +594,39 @@ internal class BucketServiceTest : ServiceTestBase() { .isEqualTo(S3Exception.NO_SUCH_LIFECYCLE_CONFIGURATION) } + @Test + fun testBucketTagging_setGetDelete() { + val bucketName = "bucket-tags" + val bucketMetadata = givenBucket(bucketName) + + // Absent -> throws + assertThatThrownBy { iut.getBucketTagging(bucketName) } + .isEqualTo(S3Exception.NO_SUCH_TAG_SET) + + // Set tags + val tags = listOf(Tag("key1", "value1"), Tag("key2", "value2")) + iut.setBucketTagging(bucketName, tags) + + // Simulate store returning updated metadata + whenever(bucketStore.getBucketMetadata(bucketName)).thenReturn( + bucketMetadata(bucketName, bucketMetadata, tags = tags) + ) + + val read = iut.getBucketTagging(bucketName) + assertThat(read).containsExactlyElementsOf(tags) + + // Delete tags and ensure they're gone + iut.deleteBucketTagging(bucketName) + + // After delete, simulate metadata without tags again + whenever(bucketStore.getBucketMetadata(bucketName)).thenReturn( + bucketMetadata(bucketName, bucketMetadata) + ) + + assertThatThrownBy { iut.getBucketTagging(bucketName) } + .isEqualTo(S3Exception.NO_SUCH_TAG_SET) + } + @Test fun testDeleteBucket_nonEmptyWithNonDeleteMarker_throws() { val bucketName = "bucket-del" @@ -687,7 +721,8 @@ internal class BucketServiceTest : ServiceTestBase() { bucketMetadata: BucketMetadata, objectLockConfiguration: ObjectLockConfiguration? = bucketMetadata.objectLockConfiguration, bucketLifecycleConfiguration: BucketLifecycleConfiguration? = bucketMetadata.bucketLifecycleConfiguration, - versioningConfiguration: VersioningConfiguration? = bucketMetadata.versioningConfiguration + versioningConfiguration: VersioningConfiguration? = bucketMetadata.versioningConfiguration, + tags: List? = bucketMetadata.tags ): BucketMetadata { return BucketMetadata( bucketName, @@ -699,7 +734,8 @@ internal class BucketServiceTest : ServiceTestBase() { bucketMetadata.path, bucketMetadata.bucketRegion, bucketMetadata.bucketInfo, - bucketMetadata.locationInfo + bucketMetadata.locationInfo, + tags ) } } From bc6b6bbeabd14a43a994788f4b93e7ad722d58d5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Feb 2026 23:16:03 +0000 Subject: [PATCH 3/3] test: improve bucket tagging tests per Kotlin/testing conventions; refine CHANGELOG Co-authored-by: afranken <763000+afranken@users.noreply.github.com> --- CHANGELOG.md | 1 + .../s3mock/controller/BucketControllerTest.kt | 30 +++++++++++++++---- .../s3mock/service/BucketServiceTest.kt | 10 ++----- 3 files changed, 27 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ab873b4e..6e2e3ad03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -146,6 +146,7 @@ Version 5.x is JDK17 LTS bytecode compatible, with Docker and JUnit / direct Jav * Features and fixes * Implement bucket tagging operations: GetBucketTagging, PutBucketTagging, DeleteBucketTagging. + * GetBucketTagging returns `NoSuchTagSet` (404) when no tags are set, matching AWS S3 behaviour. * Breaking change (file system): Remove "DisplayName" from Owner. (fixes #2738) * AWS APIs stopped returning "DisplayName" in November 2025. * This is unfortunately a breaking change for clients starting S3Mock on existing file systems. diff --git a/server/src/test/kotlin/com/adobe/testing/s3mock/controller/BucketControllerTest.kt b/server/src/test/kotlin/com/adobe/testing/s3mock/controller/BucketControllerTest.kt index 7087ad6fa..1112214f0 100644 --- a/server/src/test/kotlin/com/adobe/testing/s3mock/controller/BucketControllerTest.kt +++ b/server/src/test/kotlin/com/adobe/testing/s3mock/controller/BucketControllerTest.kt @@ -677,8 +677,7 @@ internal class BucketControllerTest : BaseControllerTest() { } @Test - @Throws(Exception::class) - fun testPutBucketTagging_Ok() { + fun `PUT bucket tagging returns OK`() { givenBucket() val tags = listOf(Tag("key1", "value1"), Tag("key2", "value2")) @@ -702,8 +701,7 @@ internal class BucketControllerTest : BaseControllerTest() { } @Test - @Throws(Exception::class) - fun testGetBucketTagging_Ok() { + fun `GET bucket tagging returns tags`() { givenBucket() val tags = listOf(Tag("key1", "value1"), Tag("key2", "value2")) @@ -725,8 +723,28 @@ internal class BucketControllerTest : BaseControllerTest() { } @Test - @Throws(Exception::class) - fun testDeleteBucketTagging_NoContent() { + fun `GET bucket tagging returns 404 when no tags are set`() { + givenBucket() + + doThrow(S3Exception.NO_SUCH_TAG_SET).whenever(bucketService).getBucketTagging(any()) + + val uri = UriComponentsBuilder + .fromUriString("/test-bucket") + .queryParam(AwsHttpParameters.TAGGING, "ignored") + .build() + .toString() + + mockMvc.perform( + get(uri) + .accept(MediaType.APPLICATION_XML) + .contentType(MediaType.APPLICATION_XML) + ) + .andExpect(status().isNotFound) + .andExpect(content().string(MAPPER.writeValueAsString(from(S3Exception.NO_SUCH_TAG_SET)))) + } + + @Test + fun `DELETE bucket tagging returns 204`() { givenBucket() val uri = UriComponentsBuilder 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 5f1071951..4e3771c37 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 @@ -595,30 +595,24 @@ internal class BucketServiceTest : ServiceTestBase() { } @Test - fun testBucketTagging_setGetDelete() { + fun `bucket tagging set get and delete`() { val bucketName = "bucket-tags" val bucketMetadata = givenBucket(bucketName) - // Absent -> throws assertThatThrownBy { iut.getBucketTagging(bucketName) } .isEqualTo(S3Exception.NO_SUCH_TAG_SET) - // Set tags val tags = listOf(Tag("key1", "value1"), Tag("key2", "value2")) iut.setBucketTagging(bucketName, tags) - // Simulate store returning updated metadata whenever(bucketStore.getBucketMetadata(bucketName)).thenReturn( bucketMetadata(bucketName, bucketMetadata, tags = tags) ) - val read = iut.getBucketTagging(bucketName) - assertThat(read).containsExactlyElementsOf(tags) + assertThat(iut.getBucketTagging(bucketName)).containsExactlyElementsOf(tags) - // Delete tags and ensure they're gone iut.deleteBucketTagging(bucketName) - // After delete, simulate metadata without tags again whenever(bucketStore.getBucketMetadata(bucketName)).thenReturn( bucketMetadata(bucketName, bucketMetadata) )