Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `DeleteBucketCors`, `GetBucketCors`, `PutBucketCors` APIs.
* 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.
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ See the [complete operations table](https://docs.aws.amazon.com/AmazonS3/latest/
| [CreateMultipartUpload](https://docs.aws.amazon.com/AmazonS3/latest/API/API_CreateMultipartUpload.html) | :white_check_mark: | |
| [DeleteBucket](https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteBucket.html) | :white_check_mark: | |
| [DeleteBucketAnalyticsConfiguration](https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteBucketAnalyticsConfiguration.html) | :x: | |
| [DeleteBucketCors](https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteBucketCors.html) | :x: | |
| [DeleteBucketCors](https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteBucketCors.html) | :white_check_mark: | |
| [DeleteBucketEncryption](https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteBucketEncryption.html) | :x: | |
| [DeleteBucketIntelligentTieringConfiguration](https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteBucketIntelligentTieringConfiguration.html) | :x: | |
| [DeleteBucketInventoryConfiguration](https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteBucketInventoryConfiguration.html) | :x: | |
Expand All @@ -127,7 +127,7 @@ See the [complete operations table](https://docs.aws.amazon.com/AmazonS3/latest/
| [GetBucketAccelerateConfiguration](https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetBucketAccelerateConfiguration.html) | :x: | |
| [GetBucketAcl](https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetBucketAcl.html) | :x: | |
| [GetBucketAnalyticsConfiguration](https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetBucketAnalyticsConfiguration.html) | :x: | |
| [GetBucketCors](https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetBucketCors.html) | :x: | |
| [GetBucketCors](https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetBucketCors.html) | :white_check_mark: | |
| [GetBucketEncryption](https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetBucketEncryption.html) | :x: | |
| [GetBucketIntelligentTieringConfiguration](https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetBucketIntelligentTieringConfiguration.html) | :x: | |
| [GetBucketInventoryConfiguration](https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetBucketInventoryConfiguration.html) | :x: | |
Expand Down Expand Up @@ -171,7 +171,7 @@ See the [complete operations table](https://docs.aws.amazon.com/AmazonS3/latest/
| [PutBucketAccelerateConfiguration](https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutBucketAccelerateConfiguration.html) | :x: | |
| [PutBucketAcl](https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutBucketAcl.html) | :x: | |
| [PutBucketAnalyticsConfiguration](https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutBucketAnalyticsConfiguration.html) | :x: | |
| [PutBucketCors](https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutBucketCors.html) | :x: | |
| [PutBucketCors](https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutBucketCors.html) | :white_check_mark: | |
| [PutBucketEncryption](https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutBucketEncryption.html) | :x: | |
| [PutBucketIntelligentTieringConfiguration](https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutBucketIntelligentTieringConfiguration.html) | :x: | |
| [PutBucketInventoryConfiguration](https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutBucketInventoryConfiguration.html) | :x: | |
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -28,9 +28,12 @@ import software.amazon.awssdk.services.s3.model.AbortIncompleteMultipartUpload
import software.amazon.awssdk.services.s3.model.BucketLifecycleConfiguration
import software.amazon.awssdk.services.s3.model.BucketType
import software.amazon.awssdk.services.s3.model.BucketVersioningStatus
import software.amazon.awssdk.services.s3.model.CORSConfiguration
import software.amazon.awssdk.services.s3.model.CORSRule
import software.amazon.awssdk.services.s3.model.DataRedundancy
import software.amazon.awssdk.services.s3.model.DeleteBucketRequest
import software.amazon.awssdk.services.s3.model.ExpirationStatus
import software.amazon.awssdk.services.s3.model.GetBucketCorsRequest
import software.amazon.awssdk.services.s3.model.GetBucketLifecycleConfigurationRequest
import software.amazon.awssdk.services.s3.model.LifecycleExpiration
import software.amazon.awssdk.services.s3.model.LifecycleRule
Expand Down Expand Up @@ -529,4 +532,86 @@ internal class BucketIT : S3TestBase() {
.extracting(AwsErrorDetails::errorCode)
.isEqualTo("NoSuchLifecycleConfiguration")
}

@Test
@S3VerifiedSuccess(year = 2026)
fun `get bucket cors returns error if not set`(testInfo: TestInfo) {
val bucketName = bucketName(testInfo)
s3Client.createBucket { it.bucket(bucketName) }

val bucketCreatedResponse =
s3Client
.waiter()
.waitUntilBucketExists { it.bucket(bucketName) }
.matched()
.response()!!
.get()
assertThat(bucketCreatedResponse).isNotNull

assertThatThrownBy {
s3Client.getBucketCors { 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("NoSuchCORSConfiguration")
}

@Test
@S3VerifiedSuccess(year = 2026)
fun `put bucket cors is successful, get bucket cors returns the config, delete is successful`(testInfo: TestInfo) {
val bucketName = bucketName(testInfo)
s3Client.createBucket { it.bucket(bucketName) }

val createdResponse =
s3Client
.waiter()
.waitUntilBucketExists { it.bucket(bucketName) }
.matched()
.response()!!
.get()
assertThat(createdResponse).isNotNull

val corsRule =
CORSRule
.builder()
.id(bucketName)
.allowedMethods("GET", "PUT")
.allowedOrigins("http://www.example.com")
.allowedHeaders("Authorization")
.exposeHeaders("x-amz-request-id")
.maxAgeSeconds(3000)
.build()

val configuration = CORSConfiguration.builder().corsRules(corsRule).build()

s3Client.putBucketCors {
it.bucket(bucketName)
it.corsConfiguration(configuration)
}

s3Client.getBucketCors { it.bucket(bucketName) }.also {
assertThat(it.corsRules()).hasSize(1)
assertThat(it.corsRules()[0].id()).isEqualTo(bucketName)
assertThat(it.corsRules()[0].allowedMethods()).containsExactly("GET", "PUT")
assertThat(it.corsRules()[0].allowedOrigins()).containsExactly("http://www.example.com")
assertThat(it.corsRules()[0].allowedHeaders()).containsExactly("Authorization")
assertThat(it.corsRules()[0].exposeHeaders()).containsExactly("x-amz-request-id")
assertThat(it.corsRules()[0].maxAgeSeconds()).isEqualTo(3000)
}

s3Client.deleteBucketCors { it.bucket(bucketName) }.also {
assertThat(it.sdkHttpResponse().statusCode()).isEqualTo(204)
}

assertThatThrownBy {
s3Client.getBucketCors(GetBucketCorsRequest.builder().bucket(bucketName).build())
}.isInstanceOf(AwsServiceException::class.java)
.hasMessageContaining("Service: S3, Status Code: 404")
.asInstanceOf(InstanceOfAssertFactories.type(AwsServiceException::class.java))
.extracting(AwsServiceException::awsErrorDetails)
.extracting(AwsErrorDetails::errorCode)
.isEqualTo("NoSuchCORSConfiguration")
}
}
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -93,6 +93,10 @@ class S3Exception
HttpStatus.NOT_FOUND.value(), "NoSuchLifecycleConfiguration",
"The lifecycle configuration does not exist."
)
val NO_SUCH_CORS_CONFIGURATION: S3Exception = S3Exception(
HttpStatus.NOT_FOUND.value(), "NoSuchCORSConfiguration",
"The CORS configuration does not exist."
)
val NO_SUCH_KEY: S3Exception =
S3Exception(HttpStatus.NOT_FOUND.value(), "NoSuchKey", "The specified key does not exist.")
val NO_SUCH_VERSION: S3Exception = S3Exception(
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -17,6 +17,7 @@ package com.adobe.testing.s3mock.controller

import com.adobe.testing.S3Verified
import com.adobe.testing.s3mock.dto.BucketLifecycleConfiguration
import com.adobe.testing.s3mock.dto.CorsConfiguration
import com.adobe.testing.s3mock.dto.CreateBucketConfiguration
import com.adobe.testing.s3mock.dto.ListAllMyBucketsResult
import com.adobe.testing.s3mock.dto.ListBucketResult
Expand All @@ -32,6 +33,8 @@ import com.adobe.testing.s3mock.util.AwsHttpHeaders.X_AMZ_BUCKET_OBJECT_LOCK_ENA
import com.adobe.testing.s3mock.util.AwsHttpHeaders.X_AMZ_BUCKET_REGION
import com.adobe.testing.s3mock.util.AwsHttpHeaders.X_AMZ_OBJECT_OWNERSHIP
import com.adobe.testing.s3mock.util.AwsHttpParameters.BUCKET_REGION
import com.adobe.testing.s3mock.util.AwsHttpParameters.CORS
import com.adobe.testing.s3mock.util.AwsHttpParameters.NOT_CORS
import com.adobe.testing.s3mock.util.AwsHttpParameters.CONTINUATION_TOKEN
import com.adobe.testing.s3mock.util.AwsHttpParameters.ENCODING_TYPE
import com.adobe.testing.s3mock.util.AwsHttpParameters.FETCH_OWNER
Expand Down Expand Up @@ -119,7 +122,8 @@ class BucketController(private val bucketService: BucketService) {
params = [
NOT_OBJECT_LOCK,
NOT_LIFECYCLE,
NOT_VERSIONING
NOT_VERSIONING,
NOT_CORS
]
)
@S3Verified(year = 2025)
Expand Down Expand Up @@ -186,7 +190,8 @@ class BucketController(private val bucketService: BucketService) {
// AWS SDK V1 pattern
"/{bucketName:.+}/"
], params = [
NOT_LIFECYCLE
NOT_LIFECYCLE,
NOT_CORS
]
)
@S3Verified(year = 2025)
Expand Down Expand Up @@ -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_GetBucketCors.html).
*/
@GetMapping(
value = [
// AWS SDK V2 pattern
"/{bucketName:.+}",
// AWS SDK V1 pattern
"/{bucketName:.+}/"
],
params = [
CORS,
NOT_LIST_TYPE
],
produces = [
MediaType.APPLICATION_XML_VALUE
]
)
@S3Verified(year = 2026)
fun getBucketCorsConfiguration(@PathVariable bucketName: String): ResponseEntity<CorsConfiguration> {
bucketService.verifyBucketExists(bucketName)
val configuration = bucketService.getBucketCorsConfiguration(bucketName)
return ResponseEntity.ok(configuration)
}

/**
* [API Reference](https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutBucketCors.html).
*/
@PutMapping(
value = [
// AWS SDK V2 pattern
"/{bucketName:.+}",
// AWS SDK V1 pattern
"/{bucketName:.+}/"
],
params = [
CORS
]
)
@S3Verified(year = 2026)
fun putBucketCorsConfiguration(
@PathVariable bucketName: String,
@RequestBody configuration: CorsConfiguration
): ResponseEntity<Void> {
bucketService.verifyBucketExists(bucketName)
bucketService.setBucketCorsConfiguration(bucketName, configuration)
return ResponseEntity.ok().build()
}

/**
* [API Reference](https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteBucketCors.html).
*/
@DeleteMapping(
value = [
// AWS SDK V2 pattern
"/{bucketName:.+}",
// AWS SDK V1 pattern
"/{bucketName:.+}/"
],
params = [
CORS
]
)
@S3Verified(year = 2026)
fun deleteBucketCorsConfiguration(@PathVariable bucketName: String): ResponseEntity<Void> {
bucketService.verifyBucketExists(bucketName)
bucketService.deleteBucketCorsConfiguration(bucketName)
return ResponseEntity.noContent().build()
}

/**
* [API Reference](https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetBucketLocation.html).
*/
Expand Down Expand Up @@ -404,6 +479,7 @@ class BucketController(private val bucketService: BucketService) {
NOT_OBJECT_LOCK,
NOT_LIST_TYPE,
NOT_LIFECYCLE,
NOT_CORS,
NOT_LOCATION,
NOT_VERSIONS,
NOT_VERSIONING
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/*
* 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.dto

import com.fasterxml.jackson.annotation.JsonProperty
import com.fasterxml.jackson.annotation.JsonRootName
import tools.jackson.dataformat.xml.annotation.JacksonXmlElementWrapper
import tools.jackson.dataformat.xml.annotation.JacksonXmlProperty

/**
* [API Reference](https://docs.aws.amazon.com/AmazonS3/latest/API/API_CORSConfiguration.html).
*/
@JsonRootName("CORSConfiguration", namespace = "http://s3.amazonaws.com/doc/2006-03-01/")
data class CorsConfiguration(
@get:JacksonXmlElementWrapper(useWrapping = false)
@get:JacksonXmlProperty(localName = "CORSRule", namespace = "http://s3.amazonaws.com/doc/2006-03-01/")
@param:JacksonXmlElementWrapper(useWrapping = false)
@param:JacksonXmlProperty(localName = "CORSRule", namespace = "http://s3.amazonaws.com/doc/2006-03-01/")
@param:JsonProperty("CORSRule", namespace = "http://s3.amazonaws.com/doc/2006-03-01/")
val corsRules: List<CorsRule>?,
)

/**
* [API Reference](https://docs.aws.amazon.com/AmazonS3/latest/API/API_CORSRule.html).
*/
data class CorsRule(
@get:JacksonXmlElementWrapper(useWrapping = false)
@get:JacksonXmlProperty(localName = "AllowedHeader", namespace = "http://s3.amazonaws.com/doc/2006-03-01/")
@param:JacksonXmlElementWrapper(useWrapping = false)
@param:JacksonXmlProperty(localName = "AllowedHeader", namespace = "http://s3.amazonaws.com/doc/2006-03-01/")
@param:JsonProperty("AllowedHeader", namespace = "http://s3.amazonaws.com/doc/2006-03-01/")
val allowedHeaders: List<String>?,

@get:JacksonXmlElementWrapper(useWrapping = false)
@get:JacksonXmlProperty(localName = "AllowedMethod", namespace = "http://s3.amazonaws.com/doc/2006-03-01/")
@param:JacksonXmlElementWrapper(useWrapping = false)
@param:JacksonXmlProperty(localName = "AllowedMethod", namespace = "http://s3.amazonaws.com/doc/2006-03-01/")
@param:JsonProperty("AllowedMethod", namespace = "http://s3.amazonaws.com/doc/2006-03-01/")
val allowedMethods: List<String>?,

@get:JacksonXmlElementWrapper(useWrapping = false)
@get:JacksonXmlProperty(localName = "AllowedOrigin", namespace = "http://s3.amazonaws.com/doc/2006-03-01/")
@param:JacksonXmlElementWrapper(useWrapping = false)
@param:JacksonXmlProperty(localName = "AllowedOrigin", namespace = "http://s3.amazonaws.com/doc/2006-03-01/")
@param:JsonProperty("AllowedOrigin", namespace = "http://s3.amazonaws.com/doc/2006-03-01/")
val allowedOrigins: List<String>?,

@get:JacksonXmlElementWrapper(useWrapping = false)
@get:JacksonXmlProperty(localName = "ExposeHeader", namespace = "http://s3.amazonaws.com/doc/2006-03-01/")
@param:JacksonXmlElementWrapper(useWrapping = false)
@param:JacksonXmlProperty(localName = "ExposeHeader", namespace = "http://s3.amazonaws.com/doc/2006-03-01/")
@param:JsonProperty("ExposeHeader", namespace = "http://s3.amazonaws.com/doc/2006-03-01/")
val exposeHeaders: List<String>?,

@get:JacksonXmlProperty(localName = "ID", namespace = "http://s3.amazonaws.com/doc/2006-03-01/")
@param:JacksonXmlProperty(localName = "ID", namespace = "http://s3.amazonaws.com/doc/2006-03-01/")
@param:JsonProperty("ID", namespace = "http://s3.amazonaws.com/doc/2006-03-01/")
val id: String?,

@get:JacksonXmlProperty(localName = "MaxAgeSeconds", namespace = "http://s3.amazonaws.com/doc/2006-03-01/")
@param:JacksonXmlProperty(localName = "MaxAgeSeconds", namespace = "http://s3.amazonaws.com/doc/2006-03-01/")
@param:JsonProperty("MaxAgeSeconds", namespace = "http://s3.amazonaws.com/doc/2006-03-01/")
val maxAgeSeconds: Int?,
)
Loading
Loading