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()