From 906f025515549778cc8d449b989385e24dd51153 Mon Sep 17 00:00:00 2001 From: Avi Kivity Date: Sat, 5 Sep 2026 21:01:19 +0300 Subject: [PATCH] Return an empty TagSet from GetObjectTagging instead of no body getObjectTagging() built its Tagging document only when the object actually carried tags, so an object with no tags produced a 200 with an empty body. S3 always answers with a Tagging document, carrying an empty TagSet when there is nothing to report, and a client that parses the documented XML fails on the empty body rather than seeing "no tags". The same applies after DeleteObjectTagging, which leaves the object with no tags at all. Build the document unconditionally and let an absent tag list serialize as . The global NON_EMPTY property inclusion does not get in the way: Jackson treats only nulls and empty containers as empty, never a POJO, so TagSet itself is always written and it is the tag list inside it that is suppressed. Cover the observable HTTP behaviour with integration tests, for an object that was never tagged and for one whose tags were deleted. Both read the raw response rather than going through the AWS SDK, which tolerates an empty body and so cannot tell "no tags" from "no document". The controller test asserts the literal wire format rather than round-tripping through the same XmlMapper the controller uses, which would have passed even if TagSet had been dropped. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 2 +- .../testing/s3mock/its/ObjectTaggingIT.kt | 72 ++++++++++++++++++- .../s3/controller/ObjectTaggingController.kt | 8 +-- .../controller/ObjectTaggingControllerTest.kt | 33 +++++++++ 4 files changed, 109 insertions(+), 6 deletions(-) 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()