diff --git a/test/fixtures/s3-fixture/src/main/java/fixture/s3/S3HttpHandler.java b/test/fixtures/s3-fixture/src/main/java/fixture/s3/S3HttpHandler.java index d282c448820a5..eeb35ed637c5d 100644 --- a/test/fixtures/s3-fixture/src/main/java/fixture/s3/S3HttpHandler.java +++ b/test/fixtures/s3-fixture/src/main/java/fixture/s3/S3HttpHandler.java @@ -45,6 +45,7 @@ import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.atomic.AtomicReference; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -248,6 +249,9 @@ public void handle(final HttpExchange exchange) throws IOException { if (isProtectOverwrite(exchange)) { throw new AssertionError("If-None-Match: * header is not supported here"); } + if (getRequiredExistingETag(exchange) != null) { + throw new AssertionError("If-Match: * header is not supported here"); + } var sourceBlob = blobs.get(copySource); if (sourceBlob == null) { @@ -406,8 +410,9 @@ public void handle(final HttpExchange exchange) throws IOException { * Update the blob contents if and only if the preconditions in the request are satisfied. * * @return {@link RestStatus#OK} if the blob contents were updated, or else a different status code to indicate the error: possibly - * {@link RestStatus#CONFLICT} or {@link RestStatus#PRECONDITION_FAILED} if the object exists and the precondition requires it - * not to. + * {@link RestStatus#CONFLICT} in any case, but if not then either {@link RestStatus#PRECONDITION_FAILED} if the object exists + * but doesn't match the specified precondition, or {@link RestStatus#NOT_FOUND} if the object doesn't exist but is required to + * do so by the precondition. * * @see AWS docs */ @@ -418,6 +423,25 @@ private RestStatus updateBlobContents(HttpExchange exchange, String path, BytesR : ESTestCase.randomFrom(RestStatus.PRECONDITION_FAILED, RestStatus.CONFLICT); } + final var requireExistingETag = getRequiredExistingETag(exchange); + if (requireExistingETag != null) { + final var responseCode = new AtomicReference<>(RestStatus.OK); + blobs.compute(path, (ignoredPath, existingContents) -> { + if (existingContents != null && requireExistingETag.equals(getEtagFromContents(existingContents))) { + return newContents; + } + + responseCode.set( + ESTestCase.randomFrom( + existingContents == null ? RestStatus.NOT_FOUND : RestStatus.PRECONDITION_FAILED, + RestStatus.CONFLICT + ) + ); + return existingContents; + }); + return responseCode.get(); + } + blobs.put(path, newContents); return RestStatus.OK; } @@ -598,6 +622,9 @@ private static boolean isProtectOverwrite(final HttpExchange exchange) { return false; } + if (exchange.getRequestHeaders().get("If-Match") != null) { + throw new AssertionError("Handling both If-None-Match and If-Match headers is not supported"); + } if (ifNoneMatch.size() != 1) { throw new AssertionError("multiple If-None-Match headers found: " + ifNoneMatch); } @@ -609,6 +636,29 @@ private static boolean isProtectOverwrite(final HttpExchange exchange) { throw new AssertionError("invalid If-None-Match header: " + ifNoneMatch); } + @Nullable // if no If-Match header found + private static String getRequiredExistingETag(final HttpExchange exchange) { + final var ifMatch = exchange.getRequestHeaders().get("If-Match"); + + if (ifMatch == null) { + return null; + } + + if (exchange.getRequestHeaders().get("If-None-Match") != null) { + throw new AssertionError("Handling both If-None-Match and If-Match headers is not supported"); + } + + final var iterator = ifMatch.iterator(); + if (iterator.hasNext()) { + final var result = iterator.next(); + if (iterator.hasNext() == false) { + return result; + } + } + + throw new AssertionError("multiple If-Match headers found: " + ifMatch); + } + MultipartUpload putUpload(String path) { final var upload = new MultipartUpload(UUIDs.randomBase64UUID(), path); synchronized (uploads) { diff --git a/test/fixtures/s3-fixture/src/test/java/fixture/s3/S3HttpHandlerTests.java b/test/fixtures/s3-fixture/src/test/java/fixture/s3/S3HttpHandlerTests.java index de4b1d95c0ae2..d1df676698d1e 100644 --- a/test/fixtures/s3-fixture/src/test/java/fixture/s3/S3HttpHandlerTests.java +++ b/test/fixtures/s3-fixture/src/test/java/fixture/s3/S3HttpHandlerTests.java @@ -412,13 +412,29 @@ public void testExtractPartEtags() { } public void testPreventObjectOverwrite() { + ensureExactlyOneSuccess(new S3HttpHandler("bucket", "path"), null); + } + + public void testConditionalOverwrite() { final var handler = new S3HttpHandler("bucket", "path"); - var tasks = List.of( - createPutObjectTask(handler), - createPutObjectTask(handler), - createMultipartUploadTask(handler), - createMultipartUploadTask(handler) + final var originalBody = new BytesArray(randomAlphaOfLength(50).getBytes(StandardCharsets.UTF_8)); + final var originalETag = S3HttpHandler.getEtagFromContents(originalBody); + assertEquals(RestStatus.OK, handleRequest(handler, "PUT", "/bucket/path/blob", originalBody).status()); + assertEquals( + new TestHttpResponse(RestStatus.OK, originalBody, addETag(originalETag, TestHttpExchange.EMPTY_HEADERS)), + handleRequest(handler, "GET", "/bucket/path/blob") + ); + + ensureExactlyOneSuccess(handler, originalETag); + } + + private static void ensureExactlyOneSuccess(S3HttpHandler handler, String originalETag) { + final var tasks = List.of( + createPutObjectTask(handler, originalETag), + createPutObjectTask(handler, originalETag), + createMultipartUploadTask(handler, originalETag), + createMultipartUploadTask(handler, originalETag) ); runInParallel(tasks.size(), i -> tasks.get(i).consumer.run()); @@ -445,13 +461,38 @@ public void testPreventObjectOverwrite() { ); } - private static TestWriteTask createPutObjectTask(S3HttpHandler handler) { + public void testPutObjectIfMatchWithBlobNotFound() { + final var handler = new S3HttpHandler("bucket", "path"); + while (true) { + final var task = createPutObjectTask(handler, randomIdentifier()); + task.consumer.run(); + if (task.status == RestStatus.NOT_FOUND) { + break; + } + assertEquals(RestStatus.CONFLICT, task.status); // chosen randomly so eventually we will escape the loop + } + } + + public void testCompleteMultipartUploadIfMatchWithBlobNotFound() { + final var handler = new S3HttpHandler("bucket", "path"); + while (true) { + final var task = createMultipartUploadTask(handler, randomIdentifier()); + task.consumer.run(); + if (task.status == RestStatus.NOT_FOUND) { + break; + } + assertEquals(RestStatus.CONFLICT, task.status); // chosen randomly so eventually we will escape the loop + } + } + + private static TestWriteTask createPutObjectTask(S3HttpHandler handler, @Nullable String originalETag) { return new TestWriteTask( - (task) -> task.status = handleRequest(handler, "PUT", "/bucket/path/blob", task.body, ifNoneMatchHeader()).status() + (task) -> task.status = handleRequest(handler, "PUT", "/bucket/path/blob", task.body, conditionalWriteHeader(originalETag)) + .status() ); } - private static TestWriteTask createMultipartUploadTask(S3HttpHandler handler) { + private static TestWriteTask createMultipartUploadTask(S3HttpHandler handler, @Nullable String originalETag) { final var multipartUploadTask = new TestWriteTask( (task) -> task.status = handleRequest( handler, @@ -465,7 +506,7 @@ private static TestWriteTask createMultipartUploadTask(S3HttpHandler handler) { 1 """, task.etag)), - ifNoneMatchHeader() + conditionalWriteHeader(originalETag) ).status() ); @@ -594,9 +635,13 @@ private static Headers contentRangeHeader(long start, long end, long length) { return headers; } - private static Headers ifNoneMatchHeader() { + private static Headers conditionalWriteHeader(@Nullable String originalEtag) { var headers = new Headers(); - headers.put("If-None-Match", List.of("*")); + if (originalEtag == null) { + headers.put("If-None-Match", List.of("*")); + } else { + headers.put("If-Match", List.of(originalEtag)); + } return headers; }