diff --git a/CHANGELOG.md b/CHANGELOG.md
index e0402f04d..a358dde3e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -153,7 +153,7 @@ Version 5.x is JDK17 LTS bytecode compatible, with Docker and JUnit / direct Jav
## 5.3.0 - PLANNED
* Features and fixes
- * TBD
+ * fix: `GetObjectTagging` now always returns a `Tagging` document with an empty `TagSet` when the object has no tags, instead of a `200` with no body. ([#3149](https://github.com/adobe/S3Mock/issues/3149))
* Version updates (deliverable dependencies)
* TBD
* Version updates (build dependencies)
diff --git a/integration-tests/src/test/kotlin/com/adobe/testing/s3mock/its/ObjectTaggingIT.kt b/integration-tests/src/test/kotlin/com/adobe/testing/s3mock/its/ObjectTaggingIT.kt
index 1b3f7000b..7d0882603 100644
--- a/integration-tests/src/test/kotlin/com/adobe/testing/s3mock/its/ObjectTaggingIT.kt
+++ b/integration-tests/src/test/kotlin/com/adobe/testing/s3mock/its/ObjectTaggingIT.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,9 +22,14 @@ import software.amazon.awssdk.core.sync.RequestBody
import software.amazon.awssdk.services.s3.S3Client
import software.amazon.awssdk.services.s3.model.Tag
import software.amazon.awssdk.services.s3.model.Tagging
+import java.net.URI
+import java.net.http.HttpClient
+import java.net.http.HttpRequest
+import java.net.http.HttpResponse
internal class ObjectTaggingIT : S3TestBase() {
private val s3Client: S3Client = createS3Client()
+ private val httpClient: HttpClient = createHttpClient()
@Test
@S3VerifiedSuccess(year = 2025)
@@ -48,6 +53,57 @@ internal class ObjectTaggingIT : S3TestBase() {
).isEmpty()
}
+ @Test
+ @S3VerifiedFailure(
+ year = 2026,
+ reason = "No credentials sent in plain HTTP request",
+ )
+ fun `GET ObjectTagging returns an empty TagSet element with no tags`(testInfo: TestInfo) {
+ val key = UPLOAD_FILE_NAME
+ val bucketName = givenBucket(testInfo)
+ s3Client.putObject(
+ {
+ it.bucket(bucketName)
+ it.key(key)
+ },
+ RequestBody.fromString("foo"),
+ )
+
+ // The AWS SDK tolerates an empty body, so it cannot tell "no tags" from "no
+ // document". Read the raw response to pin down the wire format clients parse.
+ val body = getObjectTaggingBody(bucketName, key)
+
+ assertThat(body).contains("|\\s*")
+ }
+
+ @Test
+ @S3VerifiedFailure(
+ year = 2026,
+ reason = "No credentials sent in plain HTTP request",
+ )
+ fun `GET ObjectTagging returns an empty TagSet element after DELETE`(testInfo: TestInfo) {
+ val key = UPLOAD_FILE_NAME
+ val (bucketName, _) = givenBucketAndObject(testInfo, key)
+
+ s3Client.putObjectTagging {
+ it.bucket(bucketName)
+ it.key(key)
+ it.tagging {
+ it.tagSet(tag("tag1" to "foo"))
+ }
+ }
+ s3Client.deleteObjectTagging {
+ it.bucket(bucketName)
+ it.key(key)
+ }
+
+ val body = getObjectTaggingBody(bucketName, key)
+
+ assertThat(body).contains("|\\s*")
+ }
+
@Test
@S3VerifiedSuccess(year = 2025)
fun `PUT and GET ObjectTagging succeeds`(testInfo: TestInfo) {
@@ -170,6 +226,20 @@ internal class ObjectTaggingIT : S3TestBase() {
)
}
+ private fun getObjectTaggingBody(
+ bucketName: String,
+ key: String,
+ ): String {
+ val request =
+ HttpRequest
+ .newBuilder(URI.create("$serviceEndpoint/$bucketName/$key?tagging"))
+ .GET()
+ .build()
+ val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString())
+ assertThat(response.statusCode()).isEqualTo(200)
+ return response.body()
+ }
+
private fun tag(
key: String,
value: String,
diff --git a/server/src/main/kotlin/com/adobe/testing/s3mock/s3/controller/ObjectTaggingController.kt b/server/src/main/kotlin/com/adobe/testing/s3mock/s3/controller/ObjectTaggingController.kt
index d8193ab15..a02374d61 100644
--- a/server/src/main/kotlin/com/adobe/testing/s3mock/s3/controller/ObjectTaggingController.kt
+++ b/server/src/main/kotlin/com/adobe/testing/s3mock/s3/controller/ObjectTaggingController.kt
@@ -69,10 +69,10 @@ class ObjectTaggingController(
val bucket = bucketService.verifyBucketExists(bucketName)
val s3ObjectMetadata = objectService.verifyObjectExists(bucketName, key.key, versionId)
- val tagging =
- s3ObjectMetadata.tags
- ?.takeIf { it.isNotEmpty() }
- ?.let { Tagging(TagSet(it)) }
+ // S3 always answers with a Tagging document, carrying an empty TagSet when
+ // the object has no tags. Returning no body at all makes clients that expect
+ // the documented XML fail to parse the response.
+ val tagging = Tagging(TagSet(s3ObjectMetadata.tags.orEmpty()))
return ResponseEntity
.ok()
diff --git a/server/src/test/kotlin/com/adobe/testing/s3mock/s3/controller/ObjectTaggingControllerTest.kt b/server/src/test/kotlin/com/adobe/testing/s3mock/s3/controller/ObjectTaggingControllerTest.kt
index ca4087d95..2300e436b 100644
--- a/server/src/test/kotlin/com/adobe/testing/s3mock/s3/controller/ObjectTaggingControllerTest.kt
+++ b/server/src/test/kotlin/com/adobe/testing/s3mock/s3/controller/ObjectTaggingControllerTest.kt
@@ -103,6 +103,39 @@ internal class ObjectTaggingControllerTest : BaseControllerTest() {
.andExpect(content().string(MAPPER.writeValueAsString(tagging)))
}
+ @Test
+ fun testGetObjectTagging_NoTags_ReturnsEmptyTagSet() {
+ givenBucket()
+ val key = "name"
+ // No tags were ever set on the object: S3 answers with a Tagging document
+ // carrying an empty TagSet, not with an empty body.
+ val s3ObjectMetadata = s3ObjectMetadata(key, UUID.randomUUID().toString())
+ whenever(objectService.verifyObjectExists("test-bucket", key, null))
+ .thenReturn(s3ObjectMetadata)
+
+ val uri =
+ UriComponentsBuilder
+ .fromUriString("/test-bucket/$key")
+ .queryParam(AwsHttpParameters.TAGGING, "ignored")
+ .build()
+ .toString()
+ mockMvc
+ .perform(
+ get(uri)
+ .accept(MediaType.APPLICATION_XML)
+ .contentType(MediaType.APPLICATION_XML),
+ ).andExpect(status().isOk)
+ // Assert the literal wire format instead of round-tripping through the same
+ // mapper the controller uses: the point of this test is that is
+ // present in the response body even when there is nothing to report.
+ .andExpect(
+ content().string(
+ """""" +
+ """""",
+ ),
+ )
+ }
+
@Test
fun testPutObjectTagging_Ok() {
givenBucket()