From 446ccb9087f17d419b4fb95b5276e2802d9802d4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Feb 2026 16:44:17 +0000 Subject: [PATCH 1/6] Initial plan From 80432af28e5cc7eaf56471095af04df272506d3c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Feb 2026 17:03:05 +0000 Subject: [PATCH 2/6] feat: implement bucket tagging operations (GetBucketTagging, PutBucketTagging, DeleteBucketTagging) Co-authored-by: afranken <763000+afranken@users.noreply.github.com> --- CHANGELOG.md | 1 + .../testing/s3mock/its/BucketTaggingIT.kt | 84 +++++++++++++++++++ .../s3mock/controller/BucketController.kt | 80 +++++++++++++++++- .../testing/s3mock/service/BucketService.kt | 21 ++++- .../testing/s3mock/store/BucketMetadata.kt | 7 +- .../adobe/testing/s3mock/store/BucketStore.kt | 12 ++- .../s3mock/controller/BucketControllerTest.kt | 73 ++++++++++++++++ .../s3mock/service/BucketServiceTest.kt | 42 +++++++++- .../testing/s3mock/store/BucketStoreTest.kt | 20 +++++ .../BucketMetadataTest_testSerialization.json | 1 + 10 files changed, 334 insertions(+), 7 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..318e996f9 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/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..8112cc29d --- /dev/null +++ b/integration-tests/src/test/kotlin/com/adobe/testing/s3mock/its/BucketTaggingIT.kt @@ -0,0 +1,84 @@ +/* + * 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.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInfo +import software.amazon.awssdk.services.s3.S3Client +import software.amazon.awssdk.services.s3.model.Tag + +internal class BucketTaggingIT : S3TestBase() { + private val s3Client: S3Client = createS3Client() + + @Test + @S3VerifiedTodo + fun `GET BucketTagging returns empty tag set when no tags are set`(testInfo: TestInfo) { + val bucketName = givenBucket(testInfo) + + val tagSet = s3Client.getBucketTagging { it.bucket(bucketName) }.tagSet() + + assertThat(tagSet).isEmpty() + } + + @Test + @S3VerifiedTodo + fun `PUT and GET BucketTagging succeeds`(testInfo: TestInfo) { + val bucketName = givenBucket(testInfo) + val tag1 = tag("env", "prod") + val tag2 = tag("team", "backend") + + s3Client.putBucketTagging { + it.bucket(bucketName) + it.tagging { t -> t.tagSet(tag1, tag2) } + } + + val tagSet = s3Client.getBucketTagging { it.bucket(bucketName) }.tagSet() + + assertThat(tagSet).contains(tag1, tag2) + } + + @Test + @S3VerifiedTodo + fun `PUT and GET and DELETE BucketTagging succeeds`(testInfo: TestInfo) { + val bucketName = givenBucket(testInfo) + val tag1 = tag("env", "staging") + val tag2 = tag("owner", "team-a") + + s3Client.putBucketTagging { + it.bucket(bucketName) + it.tagging { t -> t.tagSet(tag1, tag2) } + } + + assertThat(s3Client.getBucketTagging { it.bucket(bucketName) }.tagSet()) + .contains(tag1, tag2) + + s3Client.deleteBucketTagging { it.bucket(bucketName) } + + assertThat(s3Client.getBucketTagging { it.bucket(bucketName) }.tagSet()) + .isEmpty() + } + + private fun tag( + key: String, + value: String, + ): Tag = + Tag + .builder() + .key(key) + .value(value) + .build() +} 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..1b43eb8b3 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 @@ -1,5 +1,5 @@ /* - * Copyright 2017-2025 Adobe. + * 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. @@ -26,6 +26,7 @@ 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.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 +46,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,6 +122,7 @@ class BucketController(private val bucketService: BucketService) { params = [ NOT_OBJECT_LOCK, NOT_LIFECYCLE, + NOT_TAGGING, NOT_VERSIONING ] ) @@ -186,7 +190,8 @@ class BucketController(private val bucketService: BucketService) { // AWS SDK V1 pattern "/{bucketName:.+}/" ], params = [ - NOT_LIFECYCLE + NOT_LIFECYCLE, + NOT_TAGGING ] ) @S3Verified(year = 2025) @@ -365,6 +370,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 tagging = bucketService.getBucketTagging(bucketName) + return ResponseEntity.ok(tagging) + } + + /** + * [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) + 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). */ @@ -404,6 +479,7 @@ class BucketController(private val bucketService: BucketService) { NOT_OBJECT_LOCK, NOT_LIST_TYPE, NOT_LIFECYCLE, + NOT_TAGGING, NOT_LOCATION, NOT_VERSIONS, NOT_VERSIONING 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..d84102a0d 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 @@ -1,5 +1,5 @@ /* - * Copyright 2017-2025 Adobe. + * 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. @@ -34,6 +34,9 @@ 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.Tagging +import com.adobe.testing.s3mock.dto.TagSet import com.adobe.testing.s3mock.dto.VersioningConfiguration import com.adobe.testing.s3mock.store.BucketMetadata import com.adobe.testing.s3mock.store.BucketStore @@ -181,6 +184,22 @@ open class BucketService( return bucketMetadata.bucketLifecycleConfiguration ?: throw S3Exception.NO_SUCH_LIFECYCLE_CONFIGURATION } + fun getBucketTagging(bucketName: String): Tagging { + val bucketMetadata = bucketStore.getBucketMetadata(bucketName) + val tags = bucketMetadata.tagging ?: emptyList() + return Tagging(TagSet(tags)) + } + + fun setBucketTagging(bucketName: String, tagging: Tagging) { + val bucketMetadata = bucketStore.getBucketMetadata(bucketName) + bucketStore.storeBucketTagging(bucketMetadata, tagging.tagSet.tags) + } + + fun deleteBucketTagging(bucketName: String) { + val bucketMetadata = bucketStore.getBucketMetadata(bucketName) + bucketStore.storeBucketTagging(bucketMetadata, null) + } + 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..fcb8b7698 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 @@ -1,5 +1,5 @@ /* - * Copyright 2017-2025 Adobe. + * 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. @@ -21,6 +21,7 @@ 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.JsonProperty @@ -41,6 +42,7 @@ data class BucketMetadata( val bucketRegion: String, val bucketInfo: BucketInfo?, val locationInfo: LocationInfo?, + val tagging: List? = null, @param:JsonProperty("objects") private val _objects: MutableMap = mutableMapOf() ) { @@ -69,6 +71,9 @@ data class BucketMetadata( fun withBucketLifecycleConfiguration(bucketLifecycleConfiguration: BucketLifecycleConfiguration?): BucketMetadata = this.copy(bucketLifecycleConfiguration = bucketLifecycleConfiguration) + fun withTagging(tagging: List?): BucketMetadata = + this.copy(tagging = tagging) + @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..43cad62e5 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 @@ -1,5 +1,5 @@ /* - * Copyright 2017-2025 Adobe. + * 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. @@ -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, + tagging: List? + ) { + synchronized(lockStore[metadata.name]!!) { + writeToDisk(metadata.withTagging(tagging)) + } + } + 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..6f05f41f2 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,76 @@ internal class BucketControllerTest : BaseControllerTest() { verify(bucketService).deleteBucketLifecycleConfiguration(TEST_BUCKET_NAME) } + @Test + @Throws(Exception::class) + fun testGetBucketTagging_Ok() { + givenBucket() + + val tags = listOf(Tag("env", "prod"), Tag("team", "backend")) + val tagging = Tagging(TagSet(tags)) + + whenever(bucketService.getBucketTagging(TEST_BUCKET_NAME)).thenReturn(tagging) + + 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))) + } + + @Test + @Throws(Exception::class) + fun testPutBucketTagging_Ok() { + givenBucket() + + val tags = listOf(Tag("env", "prod"), Tag("team", "backend")) + 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, tagging) + } + + @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..8af513e68 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,9 @@ 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.TagSet +import com.adobe.testing.s3mock.dto.Tagging import com.adobe.testing.s3mock.dto.VersioningConfiguration import com.adobe.testing.s3mock.dto.VersioningConfiguration.Status import com.adobe.testing.s3mock.store.BucketMetadata @@ -556,6 +559,39 @@ internal class BucketServiceTest : ServiceTestBase() { assertThat(out.objectLockEnabled).isEqualTo(ObjectLockEnabled.ENABLED) } + @Test + fun testBucketTagging_setGetDelete() { + val bucketName = "bucket-tagging" + val bucketMetadata = givenBucket(bucketName) + val tags = listOf(Tag("key1", "value1"), Tag("key2", "value2")) + val tagging = Tagging(TagSet(tags)) + + // Get returns empty tag set when no tagging is set + val empty = iut.getBucketTagging(bucketName) + assertThat(empty.tagSet.tags).isEmpty() + + // Set tagging + iut.setBucketTagging(bucketName, tagging) + + // Simulate store returning updated metadata + whenever(bucketStore.getBucketMetadata(bucketName)).thenReturn( + bucketMetadata(bucketName, bucketMetadata, tagging = tags) + ) + + val read = iut.getBucketTagging(bucketName) + assertThat(read.tagSet.tags).containsExactlyElementsOf(tags) + + // Delete tagging and verify empty again + iut.deleteBucketTagging(bucketName) + + whenever(bucketStore.getBucketMetadata(bucketName)).thenReturn( + bucketMetadata(bucketName, bucketMetadata) + ) + + val afterDelete = iut.getBucketTagging(bucketName) + assertThat(afterDelete.tagSet.tags).isEmpty() + } + @Test fun testBucketLifecycleConfiguration_setGetDelete() { val bucketName = "bucket-lc" @@ -687,7 +723,8 @@ internal class BucketServiceTest : ServiceTestBase() { bucketMetadata: BucketMetadata, objectLockConfiguration: ObjectLockConfiguration? = bucketMetadata.objectLockConfiguration, bucketLifecycleConfiguration: BucketLifecycleConfiguration? = bucketMetadata.bucketLifecycleConfiguration, - versioningConfiguration: VersioningConfiguration? = bucketMetadata.versioningConfiguration + versioningConfiguration: VersioningConfiguration? = bucketMetadata.versioningConfiguration, + tagging: List? = bucketMetadata.tagging, ): BucketMetadata { return BucketMetadata( bucketName, @@ -699,7 +736,8 @@ internal class BucketServiceTest : ServiceTestBase() { bucketMetadata.path, bucketMetadata.bucketRegion, bucketMetadata.bucketInfo, - bucketMetadata.locationInfo + bucketMetadata.locationInfo, + tagging, ) } } 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 aa1f99350..4a502d45b 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 @@ -29,6 +29,7 @@ import com.adobe.testing.s3mock.dto.ObjectLockEnabled import com.adobe.testing.s3mock.dto.ObjectOwnership import com.adobe.testing.s3mock.dto.ObjectOwnership.BUCKET_OWNER_ENFORCED import com.adobe.testing.s3mock.dto.StorageClass +import com.adobe.testing.s3mock.dto.Tag import com.adobe.testing.s3mock.dto.Transition import com.adobe.testing.s3mock.dto.VersioningConfiguration import org.assertj.core.api.Assertions.assertThat @@ -132,6 +133,25 @@ internal class BucketStoreTest : StoreTestBase() { assertThat(bucket.bucketLifecycleConfiguration).isEqualTo(configuration) } + @Test + fun `stores and retrieves bucket tagging`() { + givenBucket() + + val tags = listOf(Tag("env", "prod"), Tag("team", "backend")) + + var bucket = bucketStore.getBucketMetadata(TEST_BUCKET_NAME) + bucketStore.storeBucketTagging(bucket, tags) + bucket = bucketStore.getBucketMetadata(TEST_BUCKET_NAME) + + assertThat(bucket.tagging).isEqualTo(tags) + + // clear tagging + bucketStore.storeBucketTagging(bucket, null) + bucket = bucketStore.getBucketMetadata(TEST_BUCKET_NAME) + + assertThat(bucket.tagging).isNull() + } + @Test fun `deletes empty bucket successfully`() { givenBucket() diff --git a/server/src/test/resources/com/adobe/testing/s3mock/store/BucketMetadataTest_testSerialization.json b/server/src/test/resources/com/adobe/testing/s3mock/store/BucketMetadataTest_testSerialization.json index 03b6d0d75..0fe2e627b 100644 --- a/server/src/test/resources/com/adobe/testing/s3mock/store/BucketMetadataTest_testSerialization.json +++ b/server/src/test/resources/com/adobe/testing/s3mock/store/BucketMetadataTest_testSerialization.json @@ -82,6 +82,7 @@ "Name": "us-east-1", "Type": "AvailabilityZone" }, + "tagging": null, "objects": { "src/test/resources/sampleFile_large.txt": "c6fe9dd9-2c83-4f34-a934-5da6d7d4ea2c" } From 9a3f688c2bd79b65e42ab0345ee68b531470e774 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Feb 2026 17:05:34 +0000 Subject: [PATCH 3/6] docs: add KDoc to new bucket tagging methods in BucketService and BucketStore Co-authored-by: afranken <763000+afranken@users.noreply.github.com> --- .../com/adobe/testing/s3mock/service/BucketService.kt | 9 +++++++++ .../kotlin/com/adobe/testing/s3mock/store/BucketStore.kt | 3 +++ 2 files changed, 12 insertions(+) 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 d84102a0d..319cdb740 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 @@ -184,17 +184,26 @@ open class BucketService( return bucketMetadata.bucketLifecycleConfiguration ?: throw S3Exception.NO_SUCH_LIFECYCLE_CONFIGURATION } + /** + * [API Reference](https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetBucketTagging.html). + */ fun getBucketTagging(bucketName: String): Tagging { val bucketMetadata = bucketStore.getBucketMetadata(bucketName) val tags = bucketMetadata.tagging ?: emptyList() return Tagging(TagSet(tags)) } + /** + * [API Reference](https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutBucketTagging.html). + */ fun setBucketTagging(bucketName: String, tagging: Tagging) { val bucketMetadata = bucketStore.getBucketMetadata(bucketName) bucketStore.storeBucketTagging(bucketMetadata, tagging.tagSet.tags) } + /** + * [API Reference](https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteBucketTagging.html). + */ fun deleteBucketTagging(bucketName: String) { val bucketMetadata = bucketStore.getBucketMetadata(bucketName) bucketStore.storeBucketTagging(bucketMetadata, null) 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 43cad62e5..5c2b729d6 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 @@ -196,6 +196,9 @@ open class BucketStore( } } + /** + * [API Reference](https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutBucketTagging.html). + */ fun storeBucketTagging( metadata: BucketMetadata, tagging: List? From 1908c77a259a8bf48cc5f44896bb6b294ccae6cb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Feb 2026 21:56:45 +0000 Subject: [PATCH 4/6] docs: mark GetBucketTagging, PutBucketTagging, DeleteBucketTagging as implemented in README Co-authored-by: afranken <763000+afranken@users.noreply.github.com> --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index fed931337..04dedf794 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: | | From c4bd778199baffb45e09b7312bdf182f262446ba Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Feb 2026 22:15:19 +0000 Subject: [PATCH 5/6] chore: update copyright year to 2026 in modified test files Co-authored-by: afranken <763000+afranken@users.noreply.github.com> --- .../com/adobe/testing/s3mock/controller/BucketControllerTest.kt | 2 +- .../com/adobe/testing/s3mock/service/BucketServiceTest.kt | 2 +- .../kotlin/com/adobe/testing/s3mock/store/BucketMetadataTest.kt | 2 +- .../kotlin/com/adobe/testing/s3mock/store/BucketStoreTest.kt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) 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 6f05f41f2..5b7a84003 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 @@ -1,5 +1,5 @@ /* - * Copyright 2017-2025 Adobe. + * 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. 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 8af513e68..0bcf99c56 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 @@ -1,5 +1,5 @@ /* - * Copyright 2017-2025 Adobe. + * 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. diff --git a/server/src/test/kotlin/com/adobe/testing/s3mock/store/BucketMetadataTest.kt b/server/src/test/kotlin/com/adobe/testing/s3mock/store/BucketMetadataTest.kt index bf3a08187..5888181b1 100644 --- a/server/src/test/kotlin/com/adobe/testing/s3mock/store/BucketMetadataTest.kt +++ b/server/src/test/kotlin/com/adobe/testing/s3mock/store/BucketMetadataTest.kt @@ -1,5 +1,5 @@ /* - * Copyright 2017-2025 Adobe. + * 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. 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 4a502d45b..bac8910fa 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 @@ -1,5 +1,5 @@ /* - * Copyright 2017-2025 Adobe. + * 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. From 7b4da2a2cf04dfcf547d2589f57f34bd089a066a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 11:53:29 +0000 Subject: [PATCH 6/6] docs: add copyright year update step to implement, test, and refactor skill checklists Co-authored-by: afranken <763000+afranken@users.noreply.github.com> --- .claude/skills/implement/SKILL.md | 1 + .claude/skills/refactor/SKILL.md | 1 + .claude/skills/test/SKILL.md | 1 + 3 files changed, 3 insertions(+) diff --git a/.claude/skills/implement/SKILL.md b/.claude/skills/implement/SKILL.md index 680f55b0f..c8a6759c4 100644 --- a/.claude/skills/implement/SKILL.md +++ b/.claude/skills/implement/SKILL.md @@ -32,6 +32,7 @@ Follow **DTO → Store → Service → Controller** (see AGENTS.md Architecture) - [ ] Read root + module `AGENTS.md` (required before any other step) - [ ] Identify the S3 API operation ([AWS docs](https://docs.aws.amazon.com/AmazonS3/latest/API/Welcome.html)) - [ ] Review existing similar implementations +- [ ] **Update the copyright year** to the current year in the license header of every file you modify - [ ] Run `make format` then `make install` - [ ] Invoke the **`test` skill** to add/update unit and integration tests - [ ] Invoke the **`document` skill** to update `CHANGELOG.md`, `README.md`, and `AGENTS.md` diff --git a/.claude/skills/refactor/SKILL.md b/.claude/skills/refactor/SKILL.md index 62fb428e1..22537885d 100644 --- a/.claude/skills/refactor/SKILL.md +++ b/.claude/skills/refactor/SKILL.md @@ -35,6 +35,7 @@ Document what, why, and gotchas. Link to AWS API docs where relevant. See **[doc - [ ] Verify no behavior changes — run tests - [ ] Run `make format` +- [ ] **Update the copyright year** to the current year in the license header of every file you modify - [ ] Ensure comments explain *why*, not *what* - [ ] Add KDoc for all public APIs - [ ] Use self-documenting names diff --git a/.claude/skills/test/SKILL.md b/.claude/skills/test/SKILL.md index 2acf80193..f6b982c0b 100644 --- a/.claude/skills/test/SKILL.md +++ b/.claude/skills/test/SKILL.md @@ -20,6 +20,7 @@ Read **[docs/TESTING.md](../../../docs/TESTING.md)**, **[docs/KOTLIN.md](../../. - [ ] Read `docs/TESTING.md` and root + module `AGENTS.md` - [ ] If existing tests have structural problems (poor naming, shared state, weak assertions), invoke the **`refactor` skill** to fix them rather than working around them +- [ ] **Update the copyright year** to the current year in the license header of every file you modify - [ ] Verify tests pass locally - [ ] Cover both success and failure cases - [ ] Keep tests independent (no shared state, UUID bucket names)