clazz) throws IOException {
+ T info = null;
+ if (path != null && java.nio.file.Files.exists(path)) {
+ try (FileChannel channel = FileChannel.open(path, READ)) {
+ // Lock will be released when the channel is closed
+ if (lockFileShared(channel) != null) {
+ try (java.io.InputStream is = Channels.newInputStream(channel)) {
+ info = UploadInfoJsonSerializer.deserialize(is, clazz);
+ } catch (Exception e) {
+ log.warn("Unable to read JSON file {}: {}", path, e.getMessage());
+ info = null;
+ }
+ } else {
+ throw new IOException("Unable to lock file " + path);
+ }
+ }
+ }
+ return info;
+ }
+
+ /**
+ * Writes an object to a file in JSON format, acquiring an exclusive file lock during the write
+ * operation.
+ *
+ * @param object The object to serialize to JSON
+ * @param path The file path to write to
+ * @throws IOException If file access or locking fails
+ */
+ public static void writeJson(Object object, Path path) throws IOException {
+ if (path != null) {
+ try (FileChannel channel = FileChannel.open(path, WRITE, CREATE, TRUNCATE_EXISTING)) {
+ // Lock will be released when the channel is closed
+ if (lockFileExclusively(channel) != null) {
+ try (OutputStream buffer = new BufferedOutputStream(Channels.newOutputStream(channel))) {
+ UploadInfoJsonSerializer.serializeToStream(object, buffer);
+ }
+ } else {
+ throw new IOException("Unable to lock file " + path);
+ }
+ }
+ }
+ }
+
public static FileLock lockFileExclusively(FileChannel channel) throws IOException {
return lockFile(channel, false);
}
diff --git a/src/test/java/me/desair/tus/server/AbstractITRufhProtocol.java b/src/test/java/me/desair/tus/server/AbstractITRufhProtocol.java
new file mode 100644
index 00000000..4a25227c
--- /dev/null
+++ b/src/test/java/me/desair/tus/server/AbstractITRufhProtocol.java
@@ -0,0 +1,628 @@
+package me.desair.tus.server;
+
+import static org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.containsInAnyOrder;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+import jakarta.servlet.http.HttpServletResponse;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import me.desair.tus.server.upload.UploadInfo;
+import org.apache.commons.io.IOUtils;
+import org.apache.commons.lang3.StringUtils;
+import org.junit.Before;
+import org.junit.Test;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+
+/**
+ * Abstract base integration test suite for the IETF Resumable Uploads for HTTP (RUFH) protocol.
+ *
+ * This class contains end-to-end integration test use cases covering the full RUFH lifecycle,
+ * structured into clear, step-by-step phases. Concrete subclasses supply the target storage backend
+ * by implementing {@link #createTusFileUploadService()}.
+ */
+public abstract class AbstractITRufhProtocol {
+
+ protected static final String UPLOAD_URI = "/test/upload";
+ protected static final String OWNER_KEY = "RUFH_USER";
+
+ protected MockHttpServletRequest servletRequest;
+ protected MockHttpServletResponse servletResponse;
+ protected TusFileUploadService tusFileUploadService;
+
+ /**
+ * Factory method implemented by subclasses to supply a {@link TusFileUploadService} instance
+ * configured for a specific storage backend (e.g., Disk, S3, Azure Blob).
+ *
+ * @return configured TusFileUploadService instance
+ * @throws Exception if service creation fails
+ */
+ protected abstract TusFileUploadService createTusFileUploadService() throws Exception;
+
+ @Before
+ public void setUp() throws Exception {
+ reset();
+ tusFileUploadService = createTusFileUploadService();
+ }
+
+ /** Resets mock HTTP request and response objects for a new request step. */
+ protected void reset() {
+ servletRequest = new MockHttpServletRequest();
+ servletRequest.setRemoteAddr("192.168.1.1");
+ servletResponse = new MockHttpServletResponse();
+ }
+
+ // ===============================================================================================
+ // USE CASE 1: OPTIONS Discovery
+ // ===============================================================================================
+
+ /**
+ * Section 4.1.4 (Limits - Structured Field Format): "Upload-Limit MUST be a Dictionary Structured
+ * Header Field..."
+ *
+ *
Use Case: Client sends an OPTIONS request to discover supported features and upload limits.
+ */
+ @Test
+ public void testOptionsDiscovery() throws Exception {
+ // Step 1: Send OPTIONS discovery request
+ servletRequest.setMethod("OPTIONS");
+ servletRequest.setRequestURI(UPLOAD_URI);
+ tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
+
+ // Step 2: Verify HTTP 204 response with Upload-Limit header and enabled protocol features
+ assertResponseStatus(HttpServletResponse.SC_NO_CONTENT);
+ assertResponseHeaderNotBlank(HttpHeader.UPLOAD_LIMIT);
+
+ assertThat(
+ tusFileUploadService.getEnabledFeatures(),
+ containsInAnyOrder(
+ "core",
+ "creation",
+ "creation-with-upload",
+ "checksum",
+ "termination",
+ "download",
+ "expiration",
+ "concatenation",
+ "cors",
+ "resumable-uploads-for-http",
+ "http-digests"));
+ }
+
+ // ===============================================================================================
+ // USE CASE 2: Single-Request Optimistic Upload Creation and Completion
+ // ===============================================================================================
+
+ /**
+ * Section 4.2.1 & 4.2.2 (Upload Creation - Optimistic Uploads): "If the Upload-Complete request
+ * header field is set to true, the client intends to transfer the entire representation data in
+ * one request..."
+ *
+ *
Use Case: Client uploads small payload in a single POST request using Upload-Complete: ?1.
+ */
+ @Test
+ public void testOptimisticUploadCreationAndCompletion() throws Exception {
+ String payload = "Hello, RUFH Single Request Optimistic Upload!";
+
+ // Step 1: Send single-request optimistic upload via POST with Upload-Complete: ?1
+ servletRequest.setMethod("POST");
+ servletRequest.setRequestURI(UPLOAD_URI);
+ servletRequest.addHeader(HttpHeader.UPLOAD_COMPLETE, "?1");
+ servletRequest.setContent(payload.getBytes(StandardCharsets.UTF_8));
+
+ tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
+
+ // Step 2: Verify HTTP 200 OK response with Upload-Complete: ?1 header
+ assertResponseStatus(HttpServletResponse.SC_OK);
+ assertResponseHeader(HttpHeader.UPLOAD_COMPLETE, "?1");
+ }
+
+ // ===============================================================================================
+ // USE CASE 3: Multi-Chunk Resumable Upload Lifecycle
+ // ===============================================================================================
+
+ /**
+ * Section 4.2 & 4.4 (Resumable Upload Lifecycle): "A client can start a resumable upload... by
+ * including the Upload-Complete header field... A server applies a PATCH request with the
+ * application/partial-upload media type to append data."
+ *
+ *
Use Case: Create a resumable upload with declared length, append chunk 1, check offset via
+ * HEAD, append chunk 2 with Upload-Complete: ?1, and verify downloaded bytes.
+ */
+ @Test
+ public void testResumableUploadMultiChunkLifecycle() throws Exception {
+ String part1 = "Part 1 data of resumable upload. ";
+ String part2 = "Part 2 final data of upload.";
+ long totalLength = part1.length() + part2.length();
+
+ // Step 1: Initiate resumable upload with POST, Upload-Complete: ?0, declared Upload-Length, and
+ // initial chunk
+ servletRequest.setMethod("POST");
+ servletRequest.setRequestURI(UPLOAD_URI);
+ servletRequest.addHeader(HttpHeader.UPLOAD_COMPLETE, "?0");
+ servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, String.valueOf(totalLength));
+ servletRequest.addHeader(HttpHeader.CONTENT_TYPE, HttpHeader.CONTENT_TYPE_PARTIAL_UPLOAD);
+ servletRequest.setContent(part1.getBytes(StandardCharsets.UTF_8));
+
+ tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
+
+ // Step 2: Verify HTTP 201 Created response, Location header, and initial offset
+ assertResponseStatus(HttpServletResponse.SC_CREATED);
+ assertResponseHeaderNotBlank(HttpHeader.LOCATION);
+ assertResponseHeader(HttpHeader.UPLOAD_OFFSET, String.valueOf(part1.length()));
+ assertResponseHeader(HttpHeader.UPLOAD_COMPLETE, "?0");
+
+ String uploadLocation = servletResponse.getHeader(HttpHeader.LOCATION);
+
+ // Step 3: Query upload progress via HEAD request
+ reset();
+ servletRequest.setMethod("HEAD");
+ servletRequest.setRequestURI(uploadLocation);
+
+ tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
+
+ // Step 4: Verify offset and length reported in HEAD response
+ assertResponseStatus(HttpServletResponse.SC_NO_CONTENT);
+ assertResponseHeader(HttpHeader.UPLOAD_OFFSET, String.valueOf(part1.length()));
+ assertResponseHeader(HttpHeader.UPLOAD_LENGTH, String.valueOf(totalLength));
+ assertResponseHeader(HttpHeader.UPLOAD_COMPLETE, "?0");
+
+ // Step 5: Append final chunk via PATCH with Upload-Complete: ?1
+ reset();
+ servletRequest.setMethod("PATCH");
+ servletRequest.setRequestURI(uploadLocation);
+ servletRequest.addHeader(HttpHeader.CONTENT_TYPE, HttpHeader.CONTENT_TYPE_PARTIAL_UPLOAD);
+ servletRequest.addHeader(HttpHeader.UPLOAD_OFFSET, String.valueOf(part1.length()));
+ servletRequest.addHeader(HttpHeader.UPLOAD_COMPLETE, "?1");
+ servletRequest.setContent(part2.getBytes(StandardCharsets.UTF_8));
+
+ tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
+
+ // Step 6: Verify HTTP 200 OK completing response and final offset
+ assertResponseStatus(HttpServletResponse.SC_OK);
+ assertResponseHeader(HttpHeader.UPLOAD_OFFSET, String.valueOf(totalLength));
+ assertResponseHeader(HttpHeader.UPLOAD_COMPLETE, "?1");
+
+ // Step 7: Verify internal UploadInfo state reports upload is no longer in progress
+ UploadInfo uploadInfo = tusFileUploadService.getUploadInfo(uploadLocation, OWNER_KEY);
+ assertFalse(uploadInfo.isUploadInProgress());
+ assertThat(uploadInfo.getOffset(), is(totalLength));
+
+ // Step 8: Download uploaded content and verify byte-for-byte matching
+ try (InputStream inputStream =
+ tusFileUploadService.getUploadedBytes(uploadLocation, OWNER_KEY)) {
+ String uploadedContent = IOUtils.toString(inputStream, StandardCharsets.UTF_8);
+ assertThat(uploadedContent, is(part1 + part2));
+ }
+ }
+
+ // ===============================================================================================
+ // USE CASE 4: Careful Upload Creation (Empty Creation Request)
+ // ===============================================================================================
+
+ /**
+ * Section 10.2 (Careful Upload Creation): "A client MAY create a resumable upload resource
+ * without uploading any data by sending an empty request with Upload-Complete: ?0."
+ *
+ *
Use Case: Client creates an empty upload resource without payload, then appends data in a
+ * subsequent PATCH request.
+ */
+ @Test
+ public void testCarefulUploadCreation() throws Exception {
+ // Step 1: Create empty upload resource via POST with Upload-Complete: ?0 and Upload-Length
+ servletRequest.setMethod("POST");
+ servletRequest.setRequestURI(UPLOAD_URI);
+ servletRequest.addHeader(HttpHeader.UPLOAD_COMPLETE, "?0");
+ servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, "100");
+
+ tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
+
+ // Step 2: Verify HTTP 201 Created with Location header and offset 0
+ assertResponseStatus(HttpServletResponse.SC_CREATED);
+ assertResponseHeaderNotBlank(HttpHeader.LOCATION);
+ assertResponseHeader(HttpHeader.UPLOAD_OFFSET, "0");
+ assertResponseHeader(HttpHeader.UPLOAD_COMPLETE, "?0");
+
+ String uploadLocation = servletResponse.getHeader(HttpHeader.LOCATION);
+
+ // Step 3: Append data to the created resource via PATCH at offset 0
+ reset();
+ servletRequest.setMethod("PATCH");
+ servletRequest.setRequestURI(uploadLocation);
+ servletRequest.addHeader(HttpHeader.CONTENT_TYPE, HttpHeader.CONTENT_TYPE_PARTIAL_UPLOAD);
+ servletRequest.addHeader(HttpHeader.UPLOAD_OFFSET, "0");
+ servletRequest.addHeader(HttpHeader.UPLOAD_COMPLETE, "?0");
+ servletRequest.setContent("Initial data".getBytes(StandardCharsets.UTF_8));
+
+ tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
+
+ // Step 4: Verify HTTP 204 No Content response and updated offset
+ assertResponseStatus(HttpServletResponse.SC_NO_CONTENT);
+ assertResponseHeader(HttpHeader.UPLOAD_OFFSET, "12");
+ }
+
+ // ===============================================================================================
+ // USE CASE 5: Unknown Length Resumable Upload Lifecycle
+ // ===============================================================================================
+
+ /**
+ * Section 4.1.3 & 4.4 (Unknown Length Scenario): "If the request does not include the
+ * Upload-Length header field, the representation's length is unknown... The representation's
+ * length is derived from a completing append."
+ *
+ *
Use Case: Stream chunks when total length is initially unknown, then conclude with a
+ * completing append that locks in the final length.
+ */
+ @Test
+ public void testUnknownLengthUploadLifecycle() throws Exception {
+ String part1 = "Chunk 1 data. ";
+ String part2 = "Chunk 2 data. ";
+ String part3 = "Final chunk.";
+ long totalLength = part1.length() + part2.length() + part3.length();
+
+ // Step 1: Initiate upload without Upload-Length header
+ servletRequest.setMethod("POST");
+ servletRequest.setRequestURI(UPLOAD_URI);
+ servletRequest.addHeader(HttpHeader.UPLOAD_COMPLETE, "?0");
+ servletRequest.addHeader(HttpHeader.CONTENT_TYPE, HttpHeader.CONTENT_TYPE_PARTIAL_UPLOAD);
+ servletRequest.setContent(part1.getBytes(StandardCharsets.UTF_8));
+
+ tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
+
+ // Step 2: Verify HTTP 201 Created response without Upload-Length header
+ assertResponseStatus(HttpServletResponse.SC_CREATED);
+ String uploadLocation = servletResponse.getHeader(HttpHeader.LOCATION);
+ assertResponseHeader(HttpHeader.UPLOAD_OFFSET, String.valueOf(part1.length()));
+ assertNull(servletResponse.getHeader(HttpHeader.UPLOAD_LENGTH));
+
+ // Step 3: Append second chunk without setting Upload-Complete: ?1
+ reset();
+ servletRequest.setMethod("PATCH");
+ servletRequest.setRequestURI(uploadLocation);
+ servletRequest.addHeader(HttpHeader.CONTENT_TYPE, HttpHeader.CONTENT_TYPE_PARTIAL_UPLOAD);
+ servletRequest.addHeader(HttpHeader.UPLOAD_OFFSET, String.valueOf(part1.length()));
+ servletRequest.addHeader(HttpHeader.UPLOAD_COMPLETE, "?0");
+ servletRequest.setContent(part2.getBytes(StandardCharsets.UTF_8));
+
+ tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
+
+ // Step 4: Verify HTTP 204 No Content response and intermediate offset
+ assertResponseStatus(HttpServletResponse.SC_NO_CONTENT);
+ assertResponseHeader(HttpHeader.UPLOAD_OFFSET, String.valueOf(part1.length() + part2.length()));
+ assertNull(servletResponse.getHeader(HttpHeader.UPLOAD_LENGTH));
+
+ // Step 5: Send final completing chunk via PATCH with Upload-Complete: ?1
+ reset();
+ servletRequest.setMethod("PATCH");
+ servletRequest.setRequestURI(uploadLocation);
+ servletRequest.addHeader(HttpHeader.CONTENT_TYPE, HttpHeader.CONTENT_TYPE_PARTIAL_UPLOAD);
+ servletRequest.addHeader(
+ HttpHeader.UPLOAD_OFFSET, String.valueOf(part1.length() + part2.length()));
+ servletRequest.addHeader(HttpHeader.UPLOAD_COMPLETE, "?1");
+ servletRequest.setContent(part3.getBytes(StandardCharsets.UTF_8));
+
+ tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
+
+ // Step 6: Verify HTTP 200 OK completing response and final total length
+ assertResponseStatus(HttpServletResponse.SC_OK);
+ assertResponseHeader(HttpHeader.UPLOAD_OFFSET, String.valueOf(totalLength));
+ assertResponseHeader(HttpHeader.UPLOAD_COMPLETE, "?1");
+
+ // Step 7: Verify HEAD request now returns the derived Upload-Length
+ reset();
+ servletRequest.setMethod("HEAD");
+ servletRequest.setRequestURI(uploadLocation);
+
+ tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
+ assertResponseStatus(HttpServletResponse.SC_NO_CONTENT);
+ assertResponseHeader(HttpHeader.UPLOAD_LENGTH, String.valueOf(totalLength));
+ assertResponseHeader(HttpHeader.UPLOAD_OFFSET, String.valueOf(totalLength));
+ }
+
+ // ===============================================================================================
+ // USE CASE 6: Upload Cancellation / Termination
+ // ===============================================================================================
+
+ /**
+ * Section 4.5 (Upload Cancellation): "The client can cancel an upload by sending a DELETE request
+ * to the upload resource..."
+ *
+ *
Use Case: Create upload, cancel via DELETE request, and verify subsequent HEAD returns 404.
+ */
+ @Test
+ public void testUploadCancellation() throws Exception {
+ // Step 1: Create an active upload resource
+ servletRequest.setMethod("POST");
+ servletRequest.setRequestURI(UPLOAD_URI);
+ servletRequest.addHeader(HttpHeader.UPLOAD_COMPLETE, "?0");
+ servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, "1000");
+
+ tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
+ assertResponseStatus(HttpServletResponse.SC_CREATED);
+ String uploadLocation = servletResponse.getHeader(HttpHeader.LOCATION);
+
+ // Step 2: Send DELETE request to cancel the upload
+ reset();
+ servletRequest.setMethod("DELETE");
+ servletRequest.setRequestURI(uploadLocation);
+
+ tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
+ assertResponseStatus(HttpServletResponse.SC_NO_CONTENT);
+
+ // Step 3: Verify resource was deactivated (HEAD returns 404 Not Found)
+ reset();
+ servletRequest.setMethod("HEAD");
+ servletRequest.setRequestURI(uploadLocation);
+
+ tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
+ assertResponseStatus(HttpServletResponse.SC_NOT_FOUND);
+ }
+
+ // ===============================================================================================
+ // USE CASE 7: Offset Mismatch Detection and Resumption
+ // ===============================================================================================
+
+ /**
+ * Section 4.4.2 & 7.1 (Mismatching Upload-Offset): "If the Upload-Offset header field value does
+ * not match the current offset... the server MUST reject the request with a 409 (Conflict) status
+ * code..."
+ *
+ *
Use Case: Send PATCH with wrong offset -> receive 409 Conflict with correct offset header ->
+ * resend PATCH with correct offset -> upload succeeds.
+ */
+ @Test
+ public void testOffsetMismatchAndResumption() throws Exception {
+ // Step 1: Create upload resource and upload 5 bytes
+ servletRequest.setMethod("POST");
+ servletRequest.setRequestURI(UPLOAD_URI);
+ servletRequest.addHeader(HttpHeader.UPLOAD_COMPLETE, "?0");
+ servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, "100");
+ servletRequest.addHeader(HttpHeader.CONTENT_TYPE, HttpHeader.CONTENT_TYPE_PARTIAL_UPLOAD);
+ servletRequest.setContent("12345".getBytes(StandardCharsets.UTF_8));
+
+ tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
+ assertResponseStatus(HttpServletResponse.SC_CREATED);
+ String uploadLocation = servletResponse.getHeader(HttpHeader.LOCATION);
+ assertResponseHeader(HttpHeader.UPLOAD_OFFSET, "5");
+
+ // Step 2: Attempt PATCH with incorrect offset 0 (server is at offset 5)
+ reset();
+ servletRequest.setMethod("PATCH");
+ servletRequest.setRequestURI(uploadLocation);
+ servletRequest.addHeader(HttpHeader.CONTENT_TYPE, HttpHeader.CONTENT_TYPE_PARTIAL_UPLOAD);
+ servletRequest.addHeader(HttpHeader.UPLOAD_OFFSET, "0");
+ servletRequest.addHeader(HttpHeader.UPLOAD_COMPLETE, "?0");
+ servletRequest.setContent("6789".getBytes(StandardCharsets.UTF_8));
+
+ tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
+
+ // Step 3: Verify 409 Conflict response containing current server offset (5)
+ assertResponseStatus(HttpServletResponse.SC_CONFLICT);
+ assertResponseHeader(HttpHeader.UPLOAD_OFFSET, "5");
+
+ // Step 4: Resend PATCH with correct offset 5
+ reset();
+ servletRequest.setMethod("PATCH");
+ servletRequest.setRequestURI(uploadLocation);
+ servletRequest.addHeader(HttpHeader.CONTENT_TYPE, HttpHeader.CONTENT_TYPE_PARTIAL_UPLOAD);
+ servletRequest.addHeader(HttpHeader.UPLOAD_OFFSET, "5");
+ servletRequest.addHeader(HttpHeader.UPLOAD_COMPLETE, "?0");
+ servletRequest.setContent("6789".getBytes(StandardCharsets.UTF_8));
+
+ tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
+
+ // Step 5: Verify HTTP 204 No Content response and updated offset 9
+ assertResponseStatus(HttpServletResponse.SC_NO_CONTENT);
+ assertResponseHeader(HttpHeader.UPLOAD_OFFSET, "9");
+ }
+
+ // ===============================================================================================
+ // USE CASE 8: Inconsistent Upload-Length Validation
+ // ===============================================================================================
+
+ /**
+ * Section 4.1.3 & 7.2 (Inconsistent Upload-Length): "The server MUST reject a request if the
+ * representation's length is known and inconsistent..."
+ *
+ *
Use Case: Request declares Upload-Length: 1000 but content is only 11 bytes with
+ * Upload-Complete: ?1 -> server rejects with 400 Bad Request.
+ */
+ @Test
+ public void testInconsistentUploadLength() throws Exception {
+ // Step 1: Send request declaring Upload-Length: 1000 and Upload-Complete: ?1 but providing only
+ // 11 bytes
+ servletRequest.setMethod("POST");
+ servletRequest.setRequestURI(UPLOAD_URI);
+ servletRequest.addHeader(HttpHeader.UPLOAD_COMPLETE, "?1");
+ servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, "1000");
+ servletRequest.setContent("Hello World".getBytes(StandardCharsets.UTF_8));
+
+ tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
+
+ // Step 2: Verify HTTP 400 Bad Request response
+ assertResponseStatus(HttpServletResponse.SC_BAD_REQUEST);
+ assertResponseHeader(HttpHeader.UPLOAD_COMPLETE, "?0");
+ }
+
+ // ===============================================================================================
+ // USE CASE 9: Invalid Append Headers Validation
+ // ===============================================================================================
+
+ /**
+ * Section 4.4.1 & 4.4.2 (Upload Append Validation): "The request MUST include the Upload-Offset
+ * and Upload-Complete header fields. Content-Type MUST be application/partial-upload."
+ *
+ *
Use Case: Send PATCH requests missing required headers or using wrong Content-Type -> server
+ * rejects with appropriate HTTP error codes.
+ */
+ @Test
+ public void testInvalidAppendHeaders() throws Exception {
+ // Step 1: Create upload resource
+ servletRequest.setMethod("POST");
+ servletRequest.setRequestURI(UPLOAD_URI);
+ servletRequest.addHeader(HttpHeader.UPLOAD_COMPLETE, "?0");
+ servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, "100");
+
+ tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
+ assertResponseStatus(HttpServletResponse.SC_CREATED);
+ String uploadLocation = servletResponse.getHeader(HttpHeader.LOCATION);
+
+ // Step 2: Send PATCH missing Upload-Offset -> verify HTTP 400 Bad Request
+ reset();
+ servletRequest.setMethod("PATCH");
+ servletRequest.setRequestURI(uploadLocation);
+ servletRequest.addHeader(HttpHeader.CONTENT_TYPE, HttpHeader.CONTENT_TYPE_PARTIAL_UPLOAD);
+ servletRequest.addHeader(HttpHeader.UPLOAD_COMPLETE, "?0");
+
+ tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
+ assertResponseStatus(HttpServletResponse.SC_BAD_REQUEST);
+
+ // Step 3: Send PATCH missing Upload-Complete -> verify HTTP 400 Bad Request
+ reset();
+ servletRequest.setMethod("PATCH");
+ servletRequest.setRequestURI(uploadLocation);
+ servletRequest.addHeader(HttpHeader.CONTENT_TYPE, HttpHeader.CONTENT_TYPE_PARTIAL_UPLOAD);
+ servletRequest.addHeader(HttpHeader.UPLOAD_OFFSET, "0");
+
+ tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
+ assertResponseStatus(HttpServletResponse.SC_BAD_REQUEST);
+
+ // Step 4: Send PATCH with wrong Content-Type (text/plain) -> verify HTTP 415 Unsupported Media
+ // Type
+ reset();
+ servletRequest.setMethod("PATCH");
+ servletRequest.setRequestURI(uploadLocation);
+ servletRequest.addHeader(HttpHeader.CONTENT_TYPE, "text/plain");
+ servletRequest.addHeader(HttpHeader.UPLOAD_OFFSET, "0");
+ servletRequest.addHeader(HttpHeader.UPLOAD_COMPLETE, "?0");
+
+ tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
+ assertResponseStatus(HttpServletResponse.SC_UNSUPPORTED_MEDIA_TYPE);
+ }
+
+ // ===============================================================================================
+ // USE CASE 10: Exceeding Upload-Length Rejection & Resource Invalidation
+ // ===============================================================================================
+
+ /**
+ * Section 4.4.2 (Exceeding Upload-Length): "the server MUST prevent the offset from exceeding the
+ * representation's length by rejecting the request with a 409 (Conflict) status code... marking
+ * the upload resource invalid."
+ *
+ *
Use Case: Append payload exceeding declared length -> 409 Conflict -> resource invalidation.
+ */
+ @Test
+ public void testExceedingUploadLength() throws Exception {
+ // Step 1: Create upload declaring Upload-Length: 10
+ servletRequest.setMethod("POST");
+ servletRequest.setRequestURI(UPLOAD_URI);
+ servletRequest.addHeader(HttpHeader.UPLOAD_COMPLETE, "?0");
+ servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, "10");
+
+ tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
+ assertResponseStatus(HttpServletResponse.SC_CREATED);
+ String uploadLocation = servletResponse.getHeader(HttpHeader.LOCATION);
+
+ // Step 2: Append 15 bytes (exceeding length 10)
+ reset();
+ servletRequest.setMethod("PATCH");
+ servletRequest.setRequestURI(uploadLocation);
+ servletRequest.addHeader(HttpHeader.CONTENT_TYPE, HttpHeader.CONTENT_TYPE_PARTIAL_UPLOAD);
+ servletRequest.addHeader(HttpHeader.UPLOAD_OFFSET, "0");
+ servletRequest.addHeader(HttpHeader.UPLOAD_COMPLETE, "?0");
+ servletRequest.setContent("123456789012345".getBytes(StandardCharsets.UTF_8));
+
+ tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
+
+ // Step 3: Verify 409 Conflict response
+ assertResponseStatus(HttpServletResponse.SC_CONFLICT);
+
+ // Step 4: Verify resource was invalidated (subsequent HEAD returns 404 Not Found)
+ reset();
+ servletRequest.setMethod("HEAD");
+ servletRequest.setRequestURI(uploadLocation);
+
+ tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
+ assertResponseStatus(HttpServletResponse.SC_NOT_FOUND);
+ }
+
+ // ===============================================================================================
+ // USE CASE 11: Content-Digest Validation (RFC 9530)
+ // ===============================================================================================
+
+ /**
+ * Section 3 of RFC 9530 (Content-Digest): "The Content-Digest HTTP header field associates one or
+ * more digests with a message content." If the digest does not match, the server MUST consider
+ * the transfer failed.
+ *
+ *
Use Case: Send PATCH payload with invalid sha-256 digest -> rejected with 400 Bad Request;
+ * send matching digest -> accepted with 204 No Content.
+ */
+ @Test
+ public void testContentDigestValidation() throws Exception {
+ // Step 1: Create upload resource
+ servletRequest.setMethod("POST");
+ servletRequest.setRequestURI(UPLOAD_URI);
+ servletRequest.addHeader(HttpHeader.UPLOAD_COMPLETE, "?0");
+ servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, "100");
+
+ tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
+ assertResponseStatus(HttpServletResponse.SC_CREATED);
+ String uploadLocation = servletResponse.getHeader(HttpHeader.LOCATION);
+
+ // Step 2: Send PATCH with wrong sha-256 digest
+ reset();
+ servletRequest.setMethod("PATCH");
+ servletRequest.setRequestURI(uploadLocation);
+ servletRequest.addHeader(HttpHeader.CONTENT_TYPE, HttpHeader.CONTENT_TYPE_PARTIAL_UPLOAD);
+ servletRequest.addHeader(HttpHeader.UPLOAD_OFFSET, "0");
+ servletRequest.addHeader(HttpHeader.UPLOAD_COMPLETE, "?0");
+ servletRequest.addHeader(
+ HttpHeader.CONTENT_DIGEST, "sha-256=:47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=:");
+ servletRequest.setContent("hello digest".getBytes(StandardCharsets.UTF_8));
+
+ tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
+
+ // Step 3: Verify HTTP 400 Bad Request rejection due to digest mismatch
+ assertResponseStatus(HttpServletResponse.SC_BAD_REQUEST);
+
+ // Step 4: Send PATCH with valid matching sha-256 digest for "hello digest"
+ reset();
+ servletRequest.setMethod("PATCH");
+ servletRequest.setRequestURI(uploadLocation);
+ servletRequest.addHeader(HttpHeader.CONTENT_TYPE, HttpHeader.CONTENT_TYPE_PARTIAL_UPLOAD);
+ servletRequest.addHeader(HttpHeader.UPLOAD_OFFSET, "0");
+ servletRequest.addHeader(HttpHeader.UPLOAD_COMPLETE, "?0");
+ servletRequest.addHeader(
+ HttpHeader.CONTENT_DIGEST, "sha-256=:yV9g7MInOPrtlLDWsplfHK0LaH22Uz70R1ZXbHIjzjU=:");
+ servletRequest.setContent("hello digest".getBytes(StandardCharsets.UTF_8));
+
+ tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
+
+ // Step 5: Verify HTTP 204 No Content acceptance and updated offset 12
+ assertResponseStatus(HttpServletResponse.SC_NO_CONTENT);
+ assertResponseHeader(HttpHeader.UPLOAD_OFFSET, "12");
+ }
+
+ // ===============================================================================================
+ // ASSERTION HELPERS
+ // ===============================================================================================
+
+ protected void assertResponseStatus(int expectedStatus) {
+ assertThat(servletResponse.getStatus(), is(expectedStatus));
+ }
+
+ protected void assertResponseHeader(String headerName, String expectedValue) {
+ assertThat(servletResponse.getHeader(headerName), is(expectedValue));
+ }
+
+ protected void assertResponseHeaderNotBlank(String headerName) {
+ assertTrue(
+ "Header " + headerName + " should not be blank",
+ StringUtils.isNotBlank(servletResponse.getHeader(headerName)));
+ }
+}
diff --git a/src/test/java/me/desair/tus/server/AbstractITTusFileUploadService.java b/src/test/java/me/desair/tus/server/AbstractITTusFileUploadService.java
new file mode 100644
index 00000000..c558fb36
--- /dev/null
+++ b/src/test/java/me/desair/tus/server/AbstractITTusFileUploadService.java
@@ -0,0 +1,1874 @@
+package me.desair.tus.server;
+
+import static me.desair.tus.server.util.MapMatcher.hasSize;
+import static org.hamcrest.CoreMatchers.allOf;
+import static org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.CoreMatchers.nullValue;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.containsInAnyOrder;
+import static org.hamcrest.Matchers.greaterThan;
+import static org.hamcrest.collection.IsMapContaining.hasEntry;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import jakarta.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.text.DateFormat;
+import java.text.SimpleDateFormat;
+import java.util.Arrays;
+import java.util.Locale;
+import java.util.UUID;
+import me.desair.tus.server.exception.TusException;
+import me.desair.tus.server.upload.UploadInfo;
+import me.desair.tus.server.util.Utils;
+import org.apache.commons.io.IOUtils;
+import org.apache.commons.lang3.StringUtils;
+import org.junit.Before;
+import org.junit.Test;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+
+/** Test cases for the {@link TusFileUploadService}. */
+public abstract class AbstractITTusFileUploadService {
+
+ protected abstract TusFileUploadService createTusFileUploadService() throws Exception;
+
+ protected abstract TusFileUploadService createTusFileUploadService(String uploadUri)
+ throws Exception;
+
+ protected static final String UPLOAD_URI = "/test/upload";
+ protected static final String OWNER_KEY = "JOHN_DOE";
+
+ private static final DateFormat mockDateFormat =
+ new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz", Locale.US);
+
+ protected MockHttpServletRequest servletRequest;
+ protected MockHttpServletResponse servletResponse;
+
+ protected TusFileUploadService tusFileUploadService;
+
+ @Before
+ public void setUp() throws Exception {
+ reset();
+ tusFileUploadService = createTusFileUploadService();
+ }
+
+ protected void reset() {
+ servletRequest = new MockHttpServletRequest();
+ servletRequest.setRemoteAddr("192.168.1.1");
+ servletRequest.addHeader(HttpHeader.X_FORWARDED_FOR, "10.0.2.1, 123.231.12.4");
+ servletResponse = new MockHttpServletResponse();
+ }
+
+ @Test
+ public void testSupportedHttpMethods() {
+ assertThat(
+ tusFileUploadService.getSupportedHttpMethods(),
+ containsInAnyOrder(
+ HttpMethod.HEAD,
+ HttpMethod.OPTIONS,
+ HttpMethod.PATCH,
+ HttpMethod.POST,
+ HttpMethod.PUT,
+ HttpMethod.DELETE,
+ HttpMethod.GET));
+
+ assertThat(
+ tusFileUploadService.getEnabledFeatures(),
+ containsInAnyOrder(
+ "core",
+ "creation",
+ "creation-with-upload",
+ "checksum",
+ "termination",
+ "download",
+ "expiration",
+ "concatenation",
+ "cors",
+ "resumable-uploads-for-http",
+ "http-digests"));
+ }
+
+ @Test
+ public void testDisableFeature() throws Exception {
+ tusFileUploadService.disableTusExtension("download");
+ tusFileUploadService.disableTusExtension("termination");
+ tusFileUploadService.disableTusExtension("resumable-uploads-for-http");
+ tusFileUploadService.disableTusExtension("http-digests");
+
+ assertThat(
+ tusFileUploadService.getSupportedHttpMethods(),
+ containsInAnyOrder(HttpMethod.HEAD, HttpMethod.OPTIONS, HttpMethod.PATCH, HttpMethod.POST));
+
+ assertThat(
+ tusFileUploadService.getEnabledFeatures(),
+ containsInAnyOrder(
+ "core",
+ "creation",
+ "creation-with-upload",
+ "checksum",
+ "expiration",
+ "concatenation",
+ "cors"));
+
+ reset();
+ servletRequest.setMethod("GET");
+ servletRequest.setRequestURI(UPLOAD_URI + "/" + UUID.randomUUID());
+ servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+
+ tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
+ assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
+ assertResponseStatus(HttpServletResponse.SC_METHOD_NOT_ALLOWED);
+
+ reset();
+ servletRequest.setMethod("DELETE");
+ servletRequest.setRequestURI(UPLOAD_URI + "/" + UUID.randomUUID());
+ servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+
+ tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
+ assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+ assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
+ assertResponseStatus(HttpServletResponse.SC_METHOD_NOT_ALLOWED);
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void testDisableCore() {
+ tusFileUploadService.disableTusExtension("core");
+ }
+
+ @Test(expected = NullPointerException.class)
+ public void testWithFileStoreServiceNull() throws Exception {
+ tusFileUploadService.withUploadStorageService(null);
+ }
+
+ @Test
+ public void testProcessCompleteUpload() throws Exception {
+ String uploadContent = "This is my test upload content";
+
+ // Create upload
+ servletRequest.setMethod("POST");
+ servletRequest.setRequestURI(UPLOAD_URI);
+ servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, 0);
+ servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, uploadContent.getBytes().length);
+ servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+ servletRequest.addHeader(
+ HttpHeader.UPLOAD_METADATA, "filename d29ybGRfZG9taW5hdGlvbl9wbGFuLnBkZg==");
+
+ tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
+ assertResponseHeaderNotBlank(HttpHeader.LOCATION);
+ assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+ assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
+ assertResponseHeaderNotBlank(HttpHeader.UPLOAD_EXPIRES);
+ assertResponseStatus(HttpServletResponse.SC_CREATED);
+
+ String location =
+ UPLOAD_URI
+ + StringUtils.substringAfter(
+ servletResponse.getHeader(HttpHeader.LOCATION), UPLOAD_URI);
+
+ // Upload bytes
+ reset();
+ servletRequest.setMethod("PATCH");
+ servletRequest.setRequestURI(location);
+ servletRequest.addHeader(HttpHeader.CONTENT_TYPE, "application/offset+octet-stream");
+ servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, uploadContent.getBytes().length);
+ servletRequest.addHeader(HttpHeader.UPLOAD_OFFSET, 0);
+ servletRequest.addHeader(HttpHeader.UPLOAD_CHECKSUM, "sha1 Mfhm5HaSPUf+pUakdMxARo4rvfQ=");
+ servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+ servletRequest.setContent(uploadContent.getBytes());
+
+ tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
+ assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+ assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
+ assertResponseHeader(HttpHeader.UPLOAD_OFFSET, "" + uploadContent.getBytes().length);
+ assertResponseHeaderNotBlank(HttpHeader.UPLOAD_EXPIRES);
+ assertResponseStatus(HttpServletResponse.SC_NO_CONTENT);
+
+ // Make sure cleanup does not interfere with this test
+ tusFileUploadService.cleanup();
+
+ // Check with HEAD request upload is complete
+ reset();
+ servletRequest.setMethod("HEAD");
+ servletRequest.setRequestURI(location);
+ servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+
+ tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
+ assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+ assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
+ assertResponseHeader(HttpHeader.UPLOAD_OFFSET, "" + uploadContent.getBytes().length);
+ assertResponseHeader(HttpHeader.UPLOAD_LENGTH, "" + uploadContent.getBytes().length);
+ assertResponseHeaderNull(HttpHeader.UPLOAD_DEFER_LENGTH);
+ assertResponseHeader(
+ HttpHeader.UPLOAD_METADATA, "filename d29ybGRfZG9taW5hdGlvbl9wbGFuLnBkZg==");
+ assertResponseStatus(HttpServletResponse.SC_NO_CONTENT);
+
+ // Get upload info from service
+ UploadInfo info = tusFileUploadService.getUploadInfo(location, OWNER_KEY);
+ assertFalse(info.isUploadInProgress());
+ assertThat(info.getLength(), is((long) uploadContent.getBytes().length));
+ assertThat(info.getOffset(), is((long) uploadContent.getBytes().length));
+ assertThat(
+ info.getMetadata(), allOf(hasSize(1), hasEntry("filename", "world_domination_plan.pdf")));
+ assertThat(info.getCreatorIpAddresses(), is("10.0.2.1, 123.231.12.4, 192.168.1.1"));
+
+ // Try retrieving the uploaded bytes without owner key
+ try {
+ tusFileUploadService.getUploadedBytes(location);
+ fail();
+ } catch (TusException ex) {
+ assertThat(ex.getStatus(), is(404));
+ }
+
+ // Get uploaded bytes from service
+ try (InputStream uploadedBytes = tusFileUploadService.getUploadedBytes(location, OWNER_KEY)) {
+ assertThat(
+ IOUtils.toString(uploadedBytes, StandardCharsets.UTF_8),
+ is("This is my test upload content"));
+ }
+
+ // Make sure cleanup does not interfere with this test
+ tusFileUploadService.cleanup();
+
+ // Download the upload
+ reset();
+ servletRequest.setMethod("GET");
+ servletRequest.setRequestURI(location);
+
+ tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
+ assertResponseHeader(HttpHeader.CONTENT_LENGTH, "" + uploadContent.getBytes().length);
+ assertResponseHeader(
+ HttpHeader.UPLOAD_METADATA, "filename d29ybGRfZG9taW5hdGlvbl9wbGFuLnBkZg==");
+ assertResponseStatus(HttpServletResponse.SC_OK);
+ assertThat(servletResponse.getContentAsString(), is("This is my test upload content"));
+
+ // Pretend that we processed the upload and that we can remove it
+ tusFileUploadService.deleteUpload(location, OWNER_KEY);
+
+ // Check that the upload is really gone
+ reset();
+ servletRequest.setMethod("HEAD");
+ servletRequest.setRequestURI(location);
+ servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+
+ tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
+ assertResponseStatus(HttpServletResponse.SC_NOT_FOUND);
+ assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+ assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
+ }
+
+ @Test
+ public void testProcessZeroByteUpload() throws Exception {
+ // Create upload
+ servletRequest.setMethod("POST");
+ servletRequest.setRequestURI(UPLOAD_URI);
+ servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, 0);
+ servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, 0);
+ servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+
+ tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
+ assertResponseHeaderNotBlank(HttpHeader.LOCATION);
+ assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+ assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
+ assertResponseHeaderNotBlank(HttpHeader.UPLOAD_EXPIRES);
+ assertResponseStatus(HttpServletResponse.SC_CREATED);
+
+ String location =
+ UPLOAD_URI
+ + StringUtils.substringAfter(
+ servletResponse.getHeader(HttpHeader.LOCATION), UPLOAD_URI);
+
+ // Get upload info from service
+ UploadInfo info = tusFileUploadService.getUploadInfo(location, OWNER_KEY);
+ assertFalse(info.isUploadInProgress());
+ assertThat(info.getLength(), is(0L));
+ assertThat(info.getOffset(), is(0L));
+
+ // Get uploaded bytes from service
+ try (InputStream uploadedBytes = tusFileUploadService.getUploadedBytes(location, OWNER_KEY)) {
+ assertThat(IOUtils.toString(uploadedBytes, StandardCharsets.UTF_8), is(""));
+ }
+
+ // Make sure cleanup does not interfere with this test
+ tusFileUploadService.cleanup();
+
+ // Download the upload
+ reset();
+ servletRequest.setMethod("GET");
+ servletRequest.setRequestURI(location);
+
+ tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
+ assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
+ assertResponseStatus(HttpServletResponse.SC_OK);
+ assertThat(servletResponse.getContentAsString(), is(""));
+ }
+
+ @Test
+ public void testTerminateViaHttpRequest() throws Exception {
+ String uploadContent = "This is my terminated test upload";
+
+ // Create upload
+ servletRequest.setMethod("POST");
+ servletRequest.setRequestURI(UPLOAD_URI);
+ servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, 0);
+ servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, uploadContent.getBytes().length);
+ servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+ servletRequest.addHeader(
+ HttpHeader.UPLOAD_METADATA, "filename d29ybGRfZG9taW5hdGlvbl9wbGFuLnBkZg==");
+
+ tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
+ assertResponseHeaderNotBlank(HttpHeader.LOCATION);
+ assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+ assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
+ assertResponseHeaderNotBlank(HttpHeader.UPLOAD_EXPIRES);
+ assertResponseStatus(HttpServletResponse.SC_CREATED);
+
+ String location =
+ UPLOAD_URI
+ + StringUtils.substringAfter(
+ servletResponse.getHeader(HttpHeader.LOCATION), UPLOAD_URI);
+
+ // Upload bytes
+ reset();
+ servletRequest.setMethod("PATCH");
+ servletRequest.setRequestURI(location);
+ servletRequest.addHeader(HttpHeader.CONTENT_TYPE, "application/offset+octet-stream");
+ servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, uploadContent.getBytes().length);
+ servletRequest.addHeader(HttpHeader.UPLOAD_OFFSET, 0);
+ servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+ servletRequest.setContent(uploadContent.getBytes());
+
+ tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
+ assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+ assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
+ assertResponseHeader(HttpHeader.UPLOAD_OFFSET, "" + uploadContent.getBytes().length);
+ assertResponseHeaderNotBlank(HttpHeader.UPLOAD_EXPIRES);
+ assertResponseStatus(HttpServletResponse.SC_NO_CONTENT);
+
+ // Make sure cleanup does not interfere with this test
+ tusFileUploadService.cleanup();
+
+ // Download the upload to make sure it was uploaded correctly
+ reset();
+ servletRequest.setMethod("GET");
+ servletRequest.setRequestURI(location);
+
+ tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
+ assertResponseHeader(HttpHeader.CONTENT_LENGTH, "" + uploadContent.getBytes().length);
+ assertResponseHeader(
+ HttpHeader.UPLOAD_METADATA, "filename d29ybGRfZG9taW5hdGlvbl9wbGFuLnBkZg==");
+ assertResponseStatus(HttpServletResponse.SC_OK);
+ assertThat(servletResponse.getContentAsString(), is("This is my terminated test upload"));
+
+ // Terminate the upload so that the server can remove it
+ reset();
+ servletRequest.setMethod("DELETE");
+ servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+ servletRequest.setRequestURI(location);
+
+ tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
+ assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+ assertResponseStatus(HttpServletResponse.SC_NO_CONTENT);
+
+ // Check that the upload is really gone
+ reset();
+ servletRequest.setMethod("HEAD");
+ servletRequest.setRequestURI(location);
+ servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+
+ tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
+ assertResponseStatus(HttpServletResponse.SC_NOT_FOUND);
+ assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+ assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
+ }
+
+ @Test
+ public void testProcessUploadTwoParts() throws Exception {
+ String part1 =
+ "29\r\nThis is the first part of my test upload "
+ + "\r\n0\r\nUpload-Checksum: sha1 n5RQbRwM6UVAD+9iuHEmnN6HCGQ=";
+ String part2 =
+ "1C\r\nand this is the second part."
+ + "\r\n0\r\nUpload-Checksum: sha1 oNge323kGFKICxp+Me5xJgPvGEM=";
+
+ // Create upload
+ servletRequest.setMethod("POST");
+ servletRequest.setRequestURI(UPLOAD_URI);
+ servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, 0);
+ servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, "69");
+ servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+ servletRequest.addHeader(
+ HttpHeader.UPLOAD_METADATA, "filename d29ybGRfZG9taW5hdGlvbl9wbGFuLnBkZg==");
+
+ tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
+ assertResponseHeaderNotBlank(HttpHeader.LOCATION);
+ assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+ assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
+ assertResponseHeaderNotBlank(HttpHeader.UPLOAD_EXPIRES);
+ assertResponseStatus(HttpServletResponse.SC_CREATED);
+
+ String location =
+ UPLOAD_URI
+ + StringUtils.substringAfter(
+ servletResponse.getHeader(HttpHeader.LOCATION), UPLOAD_URI);
+
+ // Make sure cleanup does not interfere with this test
+ tusFileUploadService.cleanup();
+
+ // Upload part 1 bytes
+ reset();
+ servletRequest.setMethod("PATCH");
+ servletRequest.setRequestURI(location);
+ servletRequest.addHeader(HttpHeader.CONTENT_TYPE, "application/offset+octet-stream");
+ servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, "41");
+ servletRequest.addHeader(HttpHeader.UPLOAD_OFFSET, 0);
+ servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+ servletRequest.addHeader(HttpHeader.TRANSFER_ENCODING, "chunked");
+ servletRequest.setContent(part1.getBytes());
+
+ tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
+ assertResponseStatus(HttpServletResponse.SC_NO_CONTENT);
+ assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+ assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
+ assertResponseHeaderNotBlank(HttpHeader.UPLOAD_EXPIRES);
+ assertResponseHeader(HttpHeader.UPLOAD_OFFSET, "41");
+
+ // Check with service that upload is still in progress
+ UploadInfo info = tusFileUploadService.getUploadInfo(location, OWNER_KEY);
+ assertTrue(info.isUploadInProgress());
+ assertThat(info.getLength(), is(69L));
+ assertThat(info.getOffset(), is(41L));
+ assertThat(
+ info.getMetadata(), allOf(hasSize(1), hasEntry("filename", "world_domination_plan.pdf")));
+ assertThat(info.getCreatorIpAddresses(), is("10.0.2.1, 123.231.12.4, 192.168.1.1"));
+
+ // Make sure cleanup does not interfere with this test
+ tusFileUploadService.cleanup();
+
+ // Verify that we cannot download an in-progress upload
+ reset();
+ servletRequest.setMethod("GET");
+ servletRequest.setRequestURI(location);
+
+ tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
+ assertResponseStatus(204);
+ assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
+ assertThat(servletResponse.getContentAsString(), is(""));
+
+ // Upload part 2 bytes
+ reset();
+ servletRequest.setMethod("PATCH");
+ servletRequest.setRequestURI(location);
+ servletRequest.addHeader(HttpHeader.CONTENT_TYPE, "application/offset+octet-stream");
+ servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, "28");
+ servletRequest.addHeader(HttpHeader.UPLOAD_OFFSET, "41");
+ servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+ servletRequest.addHeader(HttpHeader.TRANSFER_ENCODING, "chunked");
+ servletRequest.setContent(part2.getBytes());
+
+ tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
+ assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+ assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
+ assertResponseHeader(HttpHeader.UPLOAD_OFFSET, "69");
+ assertResponseHeaderNotBlank(HttpHeader.UPLOAD_EXPIRES);
+ assertResponseStatus(HttpServletResponse.SC_NO_CONTENT);
+
+ // Check with HEAD request upload is complete
+ reset();
+ servletRequest.setMethod("HEAD");
+ servletRequest.setRequestURI(location);
+ servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+
+ tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
+ assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+ assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
+ assertResponseHeader(HttpHeader.UPLOAD_OFFSET, "69");
+ assertResponseHeader(HttpHeader.UPLOAD_LENGTH, "69");
+ assertResponseHeaderNull(HttpHeader.UPLOAD_DEFER_LENGTH);
+ assertResponseHeader(
+ HttpHeader.UPLOAD_METADATA, "filename d29ybGRfZG9taW5hdGlvbl9wbGFuLnBkZg==");
+ assertResponseStatus(HttpServletResponse.SC_NO_CONTENT);
+
+ // Get upload info from service
+ info = tusFileUploadService.getUploadInfo(location, OWNER_KEY);
+ assertFalse(info.isUploadInProgress());
+ assertThat(info.getLength(), is(69L));
+ assertThat(info.getOffset(), is(69L));
+ assertThat(
+ info.getMetadata(), allOf(hasSize(1), hasEntry("filename", "world_domination_plan.pdf")));
+ assertThat(info.getCreatorIpAddresses(), is("10.0.2.1, 123.231.12.4, 192.168.1.1"));
+
+ // Get uploaded bytes from service
+ try (InputStream uploadedBytes = tusFileUploadService.getUploadedBytes(location, OWNER_KEY)) {
+ assertThat(
+ IOUtils.toString(uploadedBytes, StandardCharsets.UTF_8),
+ is("This is the first part of my test upload and this is the second part."));
+ }
+ }
+
+ @Test
+ public void testProcessUploadDeferredLength() throws Exception {
+ String part1 = "When sending this part, we don't know the length and ";
+ String part2 = "when sending this part, we know the length but the upload is not complete. ";
+ String part3 = "Finally when sending the third part, the upload is complete.";
+
+ // Create upload
+ servletRequest.setMethod("POST");
+ servletRequest.setRequestURI(UPLOAD_URI);
+ servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, 0);
+ servletRequest.addHeader(HttpHeader.UPLOAD_DEFER_LENGTH, 1);
+ servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+ servletRequest.addHeader(
+ HttpHeader.UPLOAD_METADATA, "filename d29ybGRfZG9taW5hdGlvbl9wbGFuLnBkZg==");
+
+ tusFileUploadService.process(servletRequest, servletResponse);
+ assertResponseHeaderNotBlank(HttpHeader.LOCATION);
+ assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+ assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
+ assertResponseHeaderNotBlank(HttpHeader.UPLOAD_EXPIRES);
+ assertResponseStatus(HttpServletResponse.SC_CREATED);
+
+ Long expirationTimestampBefore =
+ Long.parseLong(
+ String.valueOf(
+ mockDateFormat
+ .parse(servletResponse.getHeader(HttpHeader.UPLOAD_EXPIRES))
+ .getTime()));
+
+ String location =
+ UPLOAD_URI
+ + StringUtils.substringAfter(
+ servletResponse.getHeader(HttpHeader.LOCATION), UPLOAD_URI);
+
+ // Upload part 1 bytes
+ reset();
+ servletRequest.setMethod("PATCH");
+ servletRequest.setRequestURI(location);
+ servletRequest.addHeader(HttpHeader.CONTENT_TYPE, "application/offset+octet-stream");
+ servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, part1.getBytes().length);
+ servletRequest.addHeader(HttpHeader.UPLOAD_OFFSET, 0);
+ servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+ servletRequest.setContent(part1.getBytes());
+
+ tusFileUploadService.process(servletRequest, servletResponse);
+ assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+ assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
+ assertResponseHeaderNotBlank(HttpHeader.UPLOAD_EXPIRES);
+ assertResponseHeader(HttpHeader.UPLOAD_OFFSET, "" + part1.getBytes().length);
+ assertResponseStatus(HttpServletResponse.SC_NO_CONTENT);
+
+ // Check with service that upload is still in progress
+ UploadInfo info = tusFileUploadService.getUploadInfo(location, null);
+ assertTrue(info.isUploadInProgress());
+ assertThat(info.getLength(), is(nullValue()));
+ assertThat(info.getOffset(), is((long) part1.getBytes().length));
+ assertThat(
+ info.getMetadata(), allOf(hasSize(1), hasEntry("filename", "world_domination_plan.pdf")));
+ assertThat(info.getCreatorIpAddresses(), is("10.0.2.1, 123.231.12.4, 192.168.1.1"));
+
+ // Make sure cleanup does not interfere with this test
+ tusFileUploadService.cleanup();
+
+ // Check with HEAD request length is still not known
+ reset();
+ servletRequest.setMethod("HEAD");
+ servletRequest.setRequestURI(location);
+ servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+
+ tusFileUploadService.process(servletRequest, servletResponse);
+ assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+ assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
+ assertResponseHeader(HttpHeader.UPLOAD_OFFSET, "" + part1.getBytes().length);
+ assertResponseHeader(HttpHeader.UPLOAD_DEFER_LENGTH, "1");
+ assertResponseHeader(
+ HttpHeader.UPLOAD_METADATA, "filename d29ybGRfZG9taW5hdGlvbl9wbGFuLnBkZg==");
+ assertResponseStatus(HttpServletResponse.SC_NO_CONTENT);
+
+ // Upload part 2 bytes with length
+ reset();
+ servletRequest.setMethod("PATCH");
+ servletRequest.setRequestURI(location);
+ servletRequest.addHeader(HttpHeader.CONTENT_TYPE, "application/offset+octet-stream");
+ servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, part2.getBytes().length);
+ servletRequest.addHeader(HttpHeader.UPLOAD_OFFSET, part1.getBytes().length);
+ servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, (part1 + part2 + part3).getBytes().length);
+ servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+ servletRequest.setContent(part2.getBytes());
+
+ tusFileUploadService.process(servletRequest, servletResponse);
+ assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+ assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
+ assertResponseHeader(HttpHeader.UPLOAD_OFFSET, "" + (part1 + part2).getBytes().length);
+ assertResponseHeaderNotBlank(HttpHeader.UPLOAD_EXPIRES);
+ assertResponseStatus(HttpServletResponse.SC_NO_CONTENT);
+
+ // Make sure cleanup does not interfere with this test
+ tusFileUploadService.cleanup();
+
+ // Check with HEAD request length is known
+ reset();
+ servletRequest.setMethod("HEAD");
+ servletRequest.setRequestURI(location);
+ servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+
+ tusFileUploadService.process(servletRequest, servletResponse);
+ assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+ assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
+ assertResponseHeader(HttpHeader.UPLOAD_OFFSET, "" + (part1 + part2).getBytes().length);
+ assertResponseHeader(HttpHeader.UPLOAD_LENGTH, "" + (part1 + part2 + part3).getBytes().length);
+ assertResponseHeader(
+ HttpHeader.UPLOAD_METADATA, "filename d29ybGRfZG9taW5hdGlvbl9wbGFuLnBkZg==");
+ assertResponseHeaderNull(HttpHeader.UPLOAD_DEFER_LENGTH);
+ assertResponseStatus(HttpServletResponse.SC_NO_CONTENT);
+
+ info = tusFileUploadService.getUploadInfo(location, null);
+ assertTrue(info.isUploadInProgress());
+ assertThat(info.getLength(), is((long) (part1 + part2 + part3).getBytes().length));
+
+ // check that expiration timestamp was updated
+ assertThat(info.getExpirationTimestamp(), greaterThan(expirationTimestampBefore));
+
+ // Upload part 3 bytes
+ reset();
+ servletRequest.setMethod("PATCH");
+ servletRequest.setRequestURI(location);
+ servletRequest.addHeader(HttpHeader.CONTENT_TYPE, "application/offset+octet-stream");
+ servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, part3.getBytes().length);
+ servletRequest.addHeader(HttpHeader.UPLOAD_OFFSET, (part1 + part2).getBytes().length);
+ servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+ servletRequest.setContent(part3.getBytes());
+
+ tusFileUploadService.process(servletRequest, servletResponse);
+ assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+ assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
+ assertResponseHeader(HttpHeader.UPLOAD_OFFSET, "" + (part1 + part2 + part3).getBytes().length);
+ assertResponseHeaderNotBlank(HttpHeader.UPLOAD_EXPIRES);
+ assertResponseStatus(HttpServletResponse.SC_NO_CONTENT);
+
+ // Make sure cleanup does not interfere with this test
+ tusFileUploadService.cleanup();
+
+ // Get upload info from service
+ info = tusFileUploadService.getUploadInfo(location, null);
+ assertFalse(info.isUploadInProgress());
+ assertThat(info.getLength(), is((long) (part1 + part2 + part3).getBytes().length));
+ assertThat(info.getOffset(), is((long) (part1 + part2 + part3).getBytes().length));
+ assertThat(
+ info.getMetadata(), allOf(hasSize(1), hasEntry("filename", "world_domination_plan.pdf")));
+
+ // Get uploaded bytes from service
+ try (InputStream uploadedBytes = tusFileUploadService.getUploadedBytes(location, null)) {
+ assertThat(
+ IOUtils.toString(uploadedBytes, StandardCharsets.UTF_8),
+ is(
+ "When sending this part, we don't know the length and "
+ + "when sending this part, we know the length but the upload is not complete. "
+ + "Finally when sending the third part, the upload is complete."));
+ }
+
+ // Make sure cleanup does not interfere with this test
+ tusFileUploadService.cleanup();
+
+ // Download the upload
+ reset();
+ servletRequest.setMethod("GET");
+ servletRequest.setRequestURI(location);
+
+ tusFileUploadService.process(servletRequest, servletResponse, null);
+ assertResponseStatus(HttpServletResponse.SC_OK);
+ assertResponseHeader(HttpHeader.CONTENT_LENGTH, "" + (part1 + part2 + part3).getBytes().length);
+ assertResponseHeader(
+ HttpHeader.UPLOAD_METADATA, "filename d29ybGRfZG9taW5hdGlvbl9wbGFuLnBkZg==");
+ assertThat(
+ servletResponse.getContentAsString(),
+ is(
+ "When sending this part, we don't know the length and "
+ + "when sending this part, we know the length but the upload is not complete. "
+ + "Finally when sending the third part, the upload is complete."));
+ }
+
+ @Test
+ public void testProcessUploadInvalidChecksumSecondPart() throws Exception {
+ String part1 =
+ "29\r\nThis is the first part of my test upload "
+ + "\r\n0\r\nUPLOAD-CHECKSUM: sha1 n5RQbRwM6UVAD+9iuHEmnN6HCGQ=";
+ String part2 = "1C\r\nand this is the second part." + "\r\n0\r\nupload-checksum: sha1 invalid";
+
+ // Create upload
+ servletRequest.setMethod("POST");
+ servletRequest.setRequestURI(UPLOAD_URI);
+ servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, 0);
+ servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, "69");
+ servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+ servletRequest.addHeader(
+ HttpHeader.UPLOAD_METADATA, "filename d29ybGRfZG9taW5hdGlvbl9wbGFuLnBkZg==");
+
+ tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
+ assertResponseHeaderNotBlank(HttpHeader.LOCATION);
+ assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+ assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
+ assertResponseHeaderNotBlank(HttpHeader.UPLOAD_EXPIRES);
+ assertResponseStatus(HttpServletResponse.SC_CREATED);
+
+ String location =
+ UPLOAD_URI
+ + StringUtils.substringAfter(
+ servletResponse.getHeader(HttpHeader.LOCATION), UPLOAD_URI);
+
+ // Make sure cleanup does not interfere with this test
+ tusFileUploadService.cleanup();
+
+ // Upload part 1 bytes
+ reset();
+ servletRequest.setMethod("PATCH");
+ servletRequest.setRequestURI(location);
+ servletRequest.addHeader(HttpHeader.CONTENT_TYPE, "application/offset+octet-stream");
+ servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, "41");
+ servletRequest.addHeader(HttpHeader.UPLOAD_OFFSET, 0);
+ servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+ servletRequest.addHeader(HttpHeader.TRANSFER_ENCODING, "chunked");
+ servletRequest.setContent(part1.getBytes());
+
+ tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
+ assertResponseStatus(HttpServletResponse.SC_NO_CONTENT);
+ assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+ assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
+ assertResponseHeaderNotBlank(HttpHeader.UPLOAD_EXPIRES);
+ assertResponseHeader(HttpHeader.UPLOAD_OFFSET, "41");
+
+ Long expirationTimestampBefore =
+ Long.parseLong(
+ String.valueOf(
+ mockDateFormat
+ .parse(servletResponse.getHeader(HttpHeader.UPLOAD_EXPIRES))
+ .getTime()));
+
+ // Make sure cleanup does not interfere with this test
+ tusFileUploadService.cleanup();
+
+ // Upload part 2 bytes
+ reset();
+ servletRequest.setMethod("PATCH");
+ servletRequest.setRequestURI(location);
+ servletRequest.addHeader(HttpHeader.CONTENT_TYPE, "application/offset+octet-stream");
+ servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, "28");
+ servletRequest.addHeader(HttpHeader.UPLOAD_OFFSET, "41");
+ servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+ servletRequest.addHeader(HttpHeader.TRANSFER_ENCODING, "chunked");
+ servletRequest.setContent(part2.getBytes());
+
+ tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
+
+ // We expect the server to return a checksum mismatch error
+ assertResponseStatus(460);
+ assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+ assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
+
+ // Check that upload info is still from the first patch
+ UploadInfo info = tusFileUploadService.getUploadInfo(location, OWNER_KEY);
+ assertTrue(info.isUploadInProgress());
+ assertThat(info.getLength(), is(69L));
+ assertThat(info.getOffset(), is(41L));
+ assertThat(
+ info.getMetadata(), allOf(hasSize(1), hasEntry("filename", "world_domination_plan.pdf")));
+
+ // check that expiration timestamp was updated
+ assertThat(info.getExpirationTimestamp(), greaterThan(expirationTimestampBefore));
+
+ // We only stored the first valid part
+ try (InputStream uploadedBytes = tusFileUploadService.getUploadedBytes(location, OWNER_KEY)) {
+ assertThat(
+ IOUtils.toString(uploadedBytes, StandardCharsets.UTF_8),
+ is("This is the first part of my test upload "));
+ }
+
+ // Make sure cleanup does not interfere with this test
+ tusFileUploadService.cleanup();
+
+ // Terminate our in progress upload
+ reset();
+ servletRequest.setMethod("DELETE");
+ servletRequest.setRequestURI(location);
+ servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+
+ tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
+
+ // We expect the server to return a no content code to indicate successful deletion
+ assertResponseStatus(HttpServletResponse.SC_NO_CONTENT);
+ assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+ assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
+
+ // Make sure cleanup does not interfere with this test
+ tusFileUploadService.cleanup();
+
+ // Check that the upload is really gone
+ reset();
+ servletRequest.setMethod("HEAD");
+ servletRequest.setRequestURI(location);
+ servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+
+ tusFileUploadService.process(servletRequest, servletResponse);
+ assertResponseStatus(HttpServletResponse.SC_NOT_FOUND);
+ assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+ assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
+ }
+
+ @Test
+ public void testCleanupExpiredUpload() throws Exception {
+ // Set the expiration period to 500 ms
+ tusFileUploadService.withUploadExpirationPeriod(500L);
+
+ String part1 = "This is the first part of my test upload";
+ // Create upload
+ servletRequest.setMethod("POST");
+ servletRequest.setRequestURI(UPLOAD_URI);
+ servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, 0);
+ servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, part1.getBytes().length + 20L);
+ servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+
+ tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
+ assertResponseHeaderNotBlank(HttpHeader.LOCATION);
+ assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+ assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
+ assertResponseHeaderNotBlank(HttpHeader.UPLOAD_EXPIRES);
+ assertResponseStatus(HttpServletResponse.SC_CREATED);
+
+ String location =
+ UPLOAD_URI
+ + StringUtils.substringAfter(
+ servletResponse.getHeader(HttpHeader.LOCATION), UPLOAD_URI);
+
+ // Upload part 1 bytes
+ reset();
+ servletRequest.setMethod("PATCH");
+ servletRequest.setRequestURI(location);
+ servletRequest.addHeader(HttpHeader.CONTENT_TYPE, "application/offset+octet-stream");
+ servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, part1.getBytes().length);
+ servletRequest.addHeader(HttpHeader.UPLOAD_OFFSET, 0);
+ servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+ servletRequest.setContent(part1.getBytes());
+
+ tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
+ assertResponseStatus(HttpServletResponse.SC_NO_CONTENT);
+ assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+ assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
+ assertResponseHeaderNotBlank(HttpHeader.UPLOAD_EXPIRES);
+ assertResponseHeader(HttpHeader.UPLOAD_OFFSET, "" + part1.getBytes().length);
+
+ // Check with service that upload is still in progress
+ UploadInfo info = tusFileUploadService.getUploadInfo(location, OWNER_KEY);
+ assertTrue(info.isUploadInProgress());
+ assertThat(info.getLength(), is(part1.getBytes().length + 20L));
+ assertThat(info.getOffset(), is(Long.valueOf(part1.getBytes().length)));
+
+ // Now wait until the upload expired and run the cleanup
+ Utils.sleep(1000L);
+ tusFileUploadService.cleanup();
+
+ // Check with HEAD request that the upload is gone
+ // If a Client does attempt to resume an upload which has since been removed by the Server,
+ // the Server SHOULD respond with the404 Not Found or 410 Gone status.
+ reset();
+ servletRequest.setMethod("HEAD");
+ servletRequest.setRequestURI(location);
+ servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+
+ tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
+ assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+ assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
+ assertResponseStatus(HttpServletResponse.SC_NOT_FOUND);
+ }
+
+ @Test
+ public void testConcatenationCompleted() throws Exception {
+ String part1 =
+ "29\r\nThis is the first part of my test upload "
+ + "\r\n0\r\nUpload-Checksum: sha1 n5RQbRwM6UVAD+9iuHEmnN6HCGQ=";
+ String part2 =
+ "1C\r\nand this is the second part."
+ + "\r\n0\r\nUpload-Checksum: sha1 oNge323kGFKICxp+Me5xJgPvGEM=";
+
+ // Create first upload
+ servletRequest.setMethod("POST");
+ servletRequest.setRequestURI(UPLOAD_URI);
+ servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, 0);
+ servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, "41");
+ servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+ servletRequest.addHeader(HttpHeader.UPLOAD_CONCAT, "partial");
+ servletRequest.addHeader(
+ HttpHeader.UPLOAD_METADATA, "filename d29ybGRfZG9taW5hdGlvbl9wbGFuLnBkZg==");
+
+ tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
+ assertResponseHeaderNotBlank(HttpHeader.LOCATION);
+ assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+ assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
+ assertResponseHeaderNotBlank(HttpHeader.UPLOAD_EXPIRES);
+ assertResponseStatus(HttpServletResponse.SC_CREATED);
+
+ String location1 =
+ UPLOAD_URI
+ + StringUtils.substringAfter(
+ servletResponse.getHeader(HttpHeader.LOCATION), UPLOAD_URI);
+
+ // Make sure cleanup does not interfere with this test
+ tusFileUploadService.cleanup();
+
+ // Upload part 1 bytes
+ reset();
+ servletRequest.setMethod("PATCH");
+ servletRequest.setRequestURI(location1);
+ servletRequest.addHeader(HttpHeader.CONTENT_TYPE, "application/offset+octet-stream");
+ servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, "41");
+ servletRequest.addHeader(HttpHeader.UPLOAD_OFFSET, 0);
+ servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+ servletRequest.addHeader(HttpHeader.TRANSFER_ENCODING, "chunked");
+ servletRequest.setContent(part1.getBytes());
+
+ tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
+ assertResponseStatus(HttpServletResponse.SC_NO_CONTENT);
+ assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+ assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
+ assertResponseHeaderNotBlank(HttpHeader.UPLOAD_EXPIRES);
+ assertResponseHeader(HttpHeader.UPLOAD_OFFSET, "41");
+
+ // Make sure cleanup does not interfere with this test
+ tusFileUploadService.cleanup();
+
+ // Create the second upload
+ reset();
+ servletRequest.setMethod("POST");
+ servletRequest.setRequestURI(UPLOAD_URI);
+ servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, 0);
+ servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, "28");
+ servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+ servletRequest.addHeader(HttpHeader.UPLOAD_CONCAT, "partial");
+ servletRequest.addHeader(
+ HttpHeader.UPLOAD_METADATA, "filename d29ybGRfZG9taW5hdGlvbl9wbGFuLnBkZg==");
+
+ tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
+ assertResponseHeaderNotBlank(HttpHeader.LOCATION);
+ assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+ assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
+ assertResponseHeaderNotBlank(HttpHeader.UPLOAD_EXPIRES);
+ assertResponseStatus(HttpServletResponse.SC_CREATED);
+
+ String location2 =
+ UPLOAD_URI
+ + StringUtils.substringAfter(
+ servletResponse.getHeader(HttpHeader.LOCATION), UPLOAD_URI);
+
+ // Upload part 2 bytes
+ reset();
+ servletRequest.setMethod("PATCH");
+ servletRequest.setRequestURI(location2);
+ servletRequest.addHeader(HttpHeader.CONTENT_TYPE, "application/offset+octet-stream");
+ servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, "28");
+ servletRequest.addHeader(HttpHeader.UPLOAD_OFFSET, "0");
+ servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+ servletRequest.addHeader(HttpHeader.TRANSFER_ENCODING, "chunked");
+ servletRequest.setContent(part2.getBytes());
+
+ tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
+ assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+ assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
+ assertResponseHeader(HttpHeader.UPLOAD_OFFSET, "28");
+ assertResponseHeaderNotBlank(HttpHeader.UPLOAD_EXPIRES);
+ assertResponseStatus(HttpServletResponse.SC_NO_CONTENT);
+
+ // Create the final concatenated upload
+ reset();
+ servletRequest.setMethod("POST");
+ servletRequest.setRequestURI(UPLOAD_URI);
+ servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, 0);
+ servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+ servletRequest.addHeader(HttpHeader.UPLOAD_CONCAT, "final ; " + location1 + " " + location2);
+ servletRequest.addHeader(
+ HttpHeader.UPLOAD_METADATA,
+ "filename d29ybGRfZG9taW5hdGlvbl9tYXBfY29uY2F0ZW5hdGVkLnBkZg==");
+
+ tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
+ assertResponseHeaderNotBlank(HttpHeader.LOCATION);
+ assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+ assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
+ assertResponseHeaderNotBlank(HttpHeader.UPLOAD_EXPIRES);
+ assertResponseStatus(HttpServletResponse.SC_CREATED);
+
+ String location =
+ UPLOAD_URI
+ + StringUtils.substringAfter(
+ servletResponse.getHeader(HttpHeader.LOCATION), UPLOAD_URI);
+
+ // Check with HEAD request upload is complete
+ reset();
+ servletRequest.setMethod("HEAD");
+ servletRequest.setRequestURI(location);
+ servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+
+ tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
+ assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+ assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
+ assertResponseHeader(HttpHeader.UPLOAD_OFFSET, "69");
+ assertResponseHeader(HttpHeader.UPLOAD_LENGTH, "69");
+ assertResponseHeader(HttpHeader.UPLOAD_CONCAT, "final ; " + location1 + " " + location2);
+ assertResponseHeaderNull(HttpHeader.UPLOAD_DEFER_LENGTH);
+ assertResponseHeader(
+ HttpHeader.UPLOAD_METADATA,
+ "filename d29ybGRfZG9taW5hdGlvbl9tYXBfY29uY2F0ZW5hdGVkLnBkZg==");
+ assertResponseStatus(HttpServletResponse.SC_NO_CONTENT);
+
+ // Get upload info from service
+ UploadInfo info = tusFileUploadService.getUploadInfo(location, OWNER_KEY);
+ assertFalse(info.isUploadInProgress());
+ assertThat(info.getLength(), is(69L));
+ assertThat(info.getOffset(), is(69L));
+ assertThat(info.isUploadInProgress(), is(false));
+ assertThat(
+ info.getMetadata(),
+ allOf(hasSize(1), hasEntry("filename", "world_domination_map_concatenated.pdf")));
+ assertThat(info.getCreatorIpAddresses(), is("10.0.2.1, 123.231.12.4, 192.168.1.1"));
+
+ // Download the upload
+ reset();
+ servletRequest.setMethod("GET");
+ servletRequest.setRequestURI(location);
+
+ tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
+ assertResponseStatus(HttpServletResponse.SC_OK);
+ assertResponseHeader(HttpHeader.CONTENT_LENGTH, "69");
+ assertResponseHeader(
+ HttpHeader.UPLOAD_METADATA,
+ "filename d29ybGRfZG9taW5hdGlvbl9tYXBfY29uY2F0ZW5hdGVkLnBkZg==");
+ assertThat(
+ servletResponse.getContentAsString(),
+ is("This is the first part of my test upload and this is the second part."));
+
+ // Get uploaded bytes from service
+ try (InputStream uploadedBytes = tusFileUploadService.getUploadedBytes(location, OWNER_KEY)) {
+ assertThat(
+ IOUtils.toString(uploadedBytes, StandardCharsets.UTF_8),
+ is("This is the first part of my test upload and this is the second part."));
+ }
+ }
+
+ @Test
+ public void testConcatenationUnfinished() throws Exception {
+ String part1 = "When sending this part, the final upload was already created. ";
+ String part2 = "This is the second part of our concatenated upload. ";
+ String part3 = "Finally when sending the third part, the final upload is complete.";
+
+ // Create upload part 1
+ servletRequest.setMethod("POST");
+ servletRequest.setRequestURI(UPLOAD_URI);
+ servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, 0);
+ servletRequest.addHeader(HttpHeader.UPLOAD_DEFER_LENGTH, 1);
+ servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+ servletRequest.addHeader(HttpHeader.UPLOAD_CONCAT, "partial");
+ servletRequest.addHeader(HttpHeader.UPLOAD_METADATA, "filename cGFydDEucGRm");
+
+ tusFileUploadService.process(servletRequest, servletResponse);
+ assertResponseHeaderNotBlank(HttpHeader.LOCATION);
+ assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+ assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
+ assertResponseHeaderNotBlank(HttpHeader.UPLOAD_EXPIRES);
+ assertResponseStatus(HttpServletResponse.SC_CREATED);
+
+ String location1 =
+ UPLOAD_URI
+ + StringUtils.substringAfter(
+ servletResponse.getHeader(HttpHeader.LOCATION), UPLOAD_URI);
+
+ reset();
+ // Create upload part 2
+ servletRequest.setMethod("POST");
+ servletRequest.setRequestURI(UPLOAD_URI);
+ servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, 0);
+ servletRequest.addHeader(HttpHeader.UPLOAD_DEFER_LENGTH, 1);
+ servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+ servletRequest.addHeader(HttpHeader.UPLOAD_CONCAT, "partial");
+ servletRequest.addHeader(HttpHeader.UPLOAD_METADATA, "filename cGFydDIucGRm");
+
+ tusFileUploadService.process(servletRequest, servletResponse);
+ assertResponseHeaderNotBlank(HttpHeader.LOCATION);
+ assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+ assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
+ assertResponseHeaderNotBlank(HttpHeader.UPLOAD_EXPIRES);
+ assertResponseStatus(HttpServletResponse.SC_CREATED);
+
+ String location2 =
+ UPLOAD_URI
+ + StringUtils.substringAfter(
+ servletResponse.getHeader(HttpHeader.LOCATION), UPLOAD_URI);
+
+ reset();
+ // Create upload part 3
+ servletRequest.setMethod("POST");
+ servletRequest.setRequestURI(UPLOAD_URI);
+ servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, 0);
+ servletRequest.addHeader(HttpHeader.UPLOAD_DEFER_LENGTH, 1);
+ servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+ servletRequest.addHeader(HttpHeader.UPLOAD_CONCAT, "partial");
+ servletRequest.addHeader(HttpHeader.UPLOAD_METADATA, "filename cGFydDMucGRm");
+
+ tusFileUploadService.process(servletRequest, servletResponse);
+ assertResponseHeaderNotBlank(HttpHeader.LOCATION);
+ assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+ assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
+ assertResponseHeaderNotBlank(HttpHeader.UPLOAD_EXPIRES);
+ assertResponseStatus(HttpServletResponse.SC_CREATED);
+
+ String location3 =
+ UPLOAD_URI
+ + StringUtils.substringAfter(
+ servletResponse.getHeader(HttpHeader.LOCATION), UPLOAD_URI);
+
+ // Upload part 2 bytes
+ reset();
+ servletRequest.setMethod("PATCH");
+ servletRequest.setRequestURI(location2);
+ servletRequest.addHeader(HttpHeader.CONTENT_TYPE, "application/offset+octet-stream");
+ servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, part2.getBytes().length);
+ servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, part2.getBytes().length);
+ servletRequest.addHeader(HttpHeader.UPLOAD_OFFSET, 0);
+ servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+ servletRequest.setContent(part2.getBytes());
+
+ tusFileUploadService.process(servletRequest, servletResponse);
+ assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+ assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
+ assertResponseHeaderNotBlank(HttpHeader.UPLOAD_EXPIRES);
+ assertResponseHeader(HttpHeader.UPLOAD_OFFSET, "" + part2.getBytes().length);
+ assertResponseStatus(HttpServletResponse.SC_NO_CONTENT);
+
+ reset();
+ // Create final upload
+ servletRequest.setMethod("POST");
+ servletRequest.setRequestURI(UPLOAD_URI);
+ servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, 0);
+ servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+ servletRequest.addHeader(
+ HttpHeader.UPLOAD_CONCAT, "final;" + location1 + " " + location2 + " " + location3);
+ servletRequest.addHeader(HttpHeader.UPLOAD_METADATA, "filename ZmluYWwucGRm");
+
+ tusFileUploadService.process(servletRequest, servletResponse);
+ assertResponseHeaderNotBlank(HttpHeader.LOCATION);
+ assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+ assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
+ assertResponseHeaderNotBlank(HttpHeader.UPLOAD_EXPIRES);
+ assertResponseStatus(HttpServletResponse.SC_CREATED);
+
+ String locationFinal =
+ UPLOAD_URI
+ + StringUtils.substringAfter(
+ servletResponse.getHeader(HttpHeader.LOCATION), UPLOAD_URI);
+
+ // Check with HEAD request that length of final upload is undefined
+ reset();
+ servletRequest.setMethod("HEAD");
+ servletRequest.setRequestURI(locationFinal);
+ servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+
+ tusFileUploadService.process(servletRequest, servletResponse);
+ assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+ assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
+ assertResponseHeaderNull(HttpHeader.UPLOAD_OFFSET);
+ assertResponseHeaderNull(HttpHeader.UPLOAD_LENGTH);
+ assertResponseHeader(HttpHeader.UPLOAD_METADATA, "filename ZmluYWwucGRm");
+ assertResponseHeader(
+ HttpHeader.UPLOAD_CONCAT, "final;" + location1 + " " + location2 + " " + location3);
+ assertResponseHeaderNull(HttpHeader.UPLOAD_DEFER_LENGTH);
+ assertResponseStatus(HttpServletResponse.SC_NO_CONTENT);
+
+ // Verify that we cannot download an unfinished final upload
+ reset();
+ servletRequest.setMethod("GET");
+ servletRequest.setRequestURI(locationFinal);
+
+ tusFileUploadService.process(servletRequest, servletResponse);
+ assertResponseStatus(204);
+ assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
+ assertThat(servletResponse.getContentAsString(), is(""));
+
+ // Upload part 1 bytes
+ reset();
+ servletRequest.setMethod("PATCH");
+ servletRequest.setRequestURI(location1);
+ servletRequest.addHeader(HttpHeader.CONTENT_TYPE, "application/offset+octet-stream");
+ servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, part1.getBytes().length);
+ servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, part1.getBytes().length);
+ servletRequest.addHeader(HttpHeader.UPLOAD_OFFSET, 0);
+ servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+ servletRequest.setContent(part1.getBytes());
+
+ tusFileUploadService.process(servletRequest, servletResponse);
+ assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+ assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
+ assertResponseHeaderNotBlank(HttpHeader.UPLOAD_EXPIRES);
+ assertResponseHeader(HttpHeader.UPLOAD_OFFSET, "" + part1.getBytes().length);
+ assertResponseStatus(HttpServletResponse.SC_NO_CONTENT);
+
+ // Upload part 3 bytes
+ reset();
+ servletRequest.setMethod("PATCH");
+ servletRequest.setRequestURI(location3);
+ servletRequest.addHeader(HttpHeader.CONTENT_TYPE, "application/offset+octet-stream");
+ servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, part3.getBytes().length);
+ servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, part3.getBytes().length);
+ servletRequest.addHeader(HttpHeader.UPLOAD_OFFSET, 0);
+ servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+ servletRequest.setContent(part3.getBytes());
+
+ tusFileUploadService.process(servletRequest, servletResponse);
+ assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+ assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
+ assertResponseHeaderNotBlank(HttpHeader.UPLOAD_EXPIRES);
+ assertResponseHeader(HttpHeader.UPLOAD_OFFSET, "" + part3.getBytes().length);
+ assertResponseStatus(HttpServletResponse.SC_NO_CONTENT);
+
+ // Check with HEAD request length of final upload is known
+ reset();
+ servletRequest.setMethod("HEAD");
+ servletRequest.setRequestURI(locationFinal);
+ servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+
+ tusFileUploadService.process(servletRequest, servletResponse);
+ assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+ assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
+ assertResponseHeader(HttpHeader.UPLOAD_OFFSET, "" + (part1 + part2 + part3).getBytes().length);
+ assertResponseHeader(HttpHeader.UPLOAD_LENGTH, "" + (part1 + part2 + part3).getBytes().length);
+ assertResponseHeader(HttpHeader.UPLOAD_METADATA, "filename ZmluYWwucGRm");
+ assertResponseHeader(
+ HttpHeader.UPLOAD_CONCAT, "final;" + location1 + " " + location2 + " " + location3);
+ assertResponseHeaderNull(HttpHeader.UPLOAD_DEFER_LENGTH);
+ assertResponseStatus(HttpServletResponse.SC_NO_CONTENT);
+
+ // Download the upload
+ reset();
+ servletRequest.setMethod("GET");
+ servletRequest.setRequestURI(locationFinal);
+
+ tusFileUploadService.process(servletRequest, servletResponse, null);
+ assertResponseStatus(HttpServletResponse.SC_OK);
+ assertResponseHeader(HttpHeader.CONTENT_LENGTH, "" + (part1 + part2 + part3).getBytes().length);
+ assertResponseHeader(HttpHeader.UPLOAD_METADATA, "filename ZmluYWwucGRm");
+ assertThat(
+ servletResponse.getContentAsString(),
+ is(
+ "When sending this part, the final upload was already created. "
+ + "This is the second part of our concatenated upload. "
+ + "Finally when sending the third part, the final upload is complete."));
+
+ // Get uploaded bytes from service
+ try (InputStream uploadedBytes = tusFileUploadService.getUploadedBytes(locationFinal, null)) {
+ assertThat(
+ IOUtils.toString(uploadedBytes, StandardCharsets.UTF_8),
+ is(
+ "When sending this part, the final upload was already created. "
+ + "This is the second part of our concatenated upload. "
+ + "Finally when sending the third part, the final upload is complete."));
+ }
+ }
+
+ @Test
+ public void testChunkedDecodingDisabledAndRegexUploadUri() throws Exception {
+ String chunkedContent =
+ "1B;test=value\r\nThis upload looks chunked, \r\n" + "D\r\nbut it's not!\r\n" + "\r\n0\r\n";
+
+ // Create service without chunked decoding and without expiration
+ tusFileUploadService =
+ createTusFileUploadService("/users/[0-9]+/files/upload")
+ .withChunkedTransferDecoding(false)
+ .withUploadExpirationPeriod(0L);
+
+ // Create upload
+ servletRequest.setMethod("POST");
+ servletRequest.setRequestURI("/users/98765/files/upload");
+ servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, 0);
+ servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, "67");
+ servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+ servletRequest.addHeader(
+ HttpHeader.UPLOAD_METADATA, "filename d29ybGRfZG9taW5hdGlvbl9wbGFuLnBkZg==");
+
+ tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
+ assertResponseHeaderNotBlank(HttpHeader.LOCATION);
+ assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+ assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
+ assertResponseHeaderNull(HttpHeader.UPLOAD_EXPIRES);
+ assertResponseStatus(HttpServletResponse.SC_CREATED);
+
+ String location = servletResponse.getHeader(HttpHeader.LOCATION);
+
+ // Upload content
+ reset();
+ servletRequest.setMethod("PATCH");
+ servletRequest.setRequestURI(location);
+ servletRequest.addHeader(HttpHeader.CONTENT_TYPE, "application/offset+octet-stream");
+ servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, "67");
+ servletRequest.addHeader(HttpHeader.UPLOAD_OFFSET, 0);
+ servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+ servletRequest.addHeader(HttpHeader.TRANSFER_ENCODING, "chunked");
+ servletRequest.setContent(chunkedContent.getBytes());
+
+ tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
+ assertResponseStatus(HttpServletResponse.SC_NO_CONTENT);
+ assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+ assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
+ assertResponseHeaderNull(HttpHeader.UPLOAD_EXPIRES);
+ assertResponseHeader(HttpHeader.UPLOAD_OFFSET, "67");
+
+ // Check with HEAD request upload is complete
+ reset();
+ servletRequest.setMethod("HEAD");
+ servletRequest.setRequestURI(location);
+ servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+
+ tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
+ assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+ assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
+ assertResponseHeader(HttpHeader.UPLOAD_OFFSET, "67");
+ assertResponseHeader(HttpHeader.UPLOAD_LENGTH, "67");
+ assertResponseHeaderNull(HttpHeader.UPLOAD_DEFER_LENGTH);
+ assertResponseHeader(
+ HttpHeader.UPLOAD_METADATA, "filename d29ybGRfZG9taW5hdGlvbl9wbGFuLnBkZg==");
+ assertResponseStatus(HttpServletResponse.SC_NO_CONTENT);
+
+ // Get upload info from service
+ UploadInfo info = tusFileUploadService.getUploadInfo(location, OWNER_KEY);
+ assertFalse(info.isUploadInProgress());
+ assertThat(info.getLength(), is(67L));
+ assertThat(info.getOffset(), is(67L));
+ assertThat(
+ info.getMetadata(), allOf(hasSize(1), hasEntry("filename", "world_domination_plan.pdf")));
+
+ // Get uploaded bytes from service
+ try (InputStream uploadedBytes = tusFileUploadService.getUploadedBytes(location, OWNER_KEY)) {
+ assertThat(
+ IOUtils.toString(uploadedBytes, StandardCharsets.UTF_8),
+ is(
+ "1B;test=value\r\nThis upload looks chunked, \r\n"
+ + "D\r\nbut it's not!\r\n"
+ + "\r\n0\r\n"));
+ }
+ }
+
+ @Test
+ public void testOptions() throws Exception {
+ // Do options request and check response headers
+ servletRequest.setMethod("OPTIONS");
+
+ tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
+
+ assertResponseStatus(HttpServletResponse.SC_NO_CONTENT);
+ assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+ assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
+ assertResponseHeader(HttpHeader.TUS_VERSION, "1.0.0");
+ assertResponseHeader(HttpHeader.TUS_MAX_SIZE, "1073741824");
+ assertResponseHeader(
+ HttpHeader.TUS_CHECKSUM_ALGORITHM, "md5", "sha1", "sha256", "sha384", "sha512");
+ assertResponseHeader(
+ HttpHeader.TUS_EXTENSION,
+ "creation",
+ "creation-defer-length",
+ "creation-with-upload",
+ "checksum",
+ "checksum-trailer",
+ "termination",
+ "download",
+ "expiration",
+ "concatenation",
+ "concatenation-unfinished");
+ }
+
+ @Test
+ public void testHeadOnNonExistingUpload() throws Exception {
+ servletRequest.setMethod("HEAD");
+ servletRequest.setRequestURI(UPLOAD_URI + "/" + UUID.randomUUID());
+ servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+
+ tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
+ assertResponseStatus(HttpServletResponse.SC_NOT_FOUND);
+ assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+ assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
+ }
+
+ @Test
+ public void testInvalidTusResumable() throws Exception {
+ servletRequest.setMethod("POST");
+ servletRequest.setRequestURI(UPLOAD_URI);
+ servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, 0);
+ servletRequest.addHeader(HttpHeader.UPLOAD_DEFER_LENGTH, 1);
+ servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "2.0.0");
+
+ tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
+ assertResponseStatus(HttpServletResponse.SC_PRECONDITION_FAILED);
+ assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+ assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
+ }
+
+ @Test
+ public void testMaxUploadLengthExceeded() throws Exception {
+ tusFileUploadService.withMaxUploadSize(10L);
+
+ String uploadContent = "This is upload is too long";
+
+ // Create upload
+ servletRequest.setMethod("POST");
+ servletRequest.setRequestURI(UPLOAD_URI);
+ servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, 0);
+ servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, uploadContent.getBytes().length);
+ servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+
+ tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
+ assertResponseStatus(HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE);
+ assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+ assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
+ }
+
+ @Test
+ public void testInvalidMethods() throws Exception {
+ servletRequest.setMethod("PUT");
+ servletRequest.setRequestURI(UPLOAD_URI + "/" + UUID.randomUUID());
+ servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+
+ tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
+ assertResponseStatus(HttpServletResponse.SC_METHOD_NOT_ALLOWED);
+
+ reset();
+ servletRequest.setMethod("CONNECT");
+ servletRequest.setRequestURI(UPLOAD_URI + "/" + UUID.randomUUID());
+ servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+
+ tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
+ assertResponseStatus(HttpServletResponse.SC_METHOD_NOT_ALLOWED);
+ assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+ assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
+
+ reset();
+ servletRequest.setMethod("TRACE");
+ servletRequest.setRequestURI(UPLOAD_URI + "/" + UUID.randomUUID());
+ servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+
+ tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
+ assertResponseStatus(HttpServletResponse.SC_METHOD_NOT_ALLOWED);
+ assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+ assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
+ }
+
+ @Test
+ public void testLockContentionAndHeadRelease() throws Exception {
+ // 1. Create upload resource
+ servletRequest.setMethod("POST");
+ servletRequest.setRequestURI(UPLOAD_URI);
+ servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, 0);
+ servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, 1000L);
+ servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+ tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
+ String location =
+ UPLOAD_URI
+ + StringUtils.substringAfter(
+ servletResponse.getHeader(HttpHeader.LOCATION), UPLOAD_URI);
+
+ // 2. Start a blocking PATCH in a background thread to hold the lock
+ final java.util.concurrent.CountDownLatch requestStarted =
+ new java.util.concurrent.CountDownLatch(1);
+ final java.util.concurrent.atomic.AtomicReference bgException =
+ new java.util.concurrent.atomic.AtomicReference<>();
+
+ InputStream blockingStream =
+ new InputStream() {
+ private volatile boolean closed = false;
+
+ @Override
+ public int read() throws IOException {
+ requestStarted.countDown();
+ synchronized (this) {
+ while (!closed) {
+ try {
+ this.wait(100);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new IOException("Interrupted", e);
+ }
+ }
+ }
+ throw new IOException("Stream closed");
+ }
+
+ @Override
+ public void close() throws IOException {
+ synchronized (this) {
+ closed = true;
+ this.notifyAll();
+ }
+ }
+ };
+
+ final MockHttpServletRequest bgRequest =
+ new MockHttpServletRequest() {
+ @Override
+ public jakarta.servlet.ServletInputStream getInputStream() {
+ return new jakarta.servlet.ServletInputStream() {
+ @Override
+ public int read() throws IOException {
+ return blockingStream.read();
+ }
+
+ @Override
+ public void close() throws IOException {
+ blockingStream.close();
+ }
+
+ @Override
+ public boolean isFinished() {
+ return false;
+ }
+
+ @Override
+ public boolean isReady() {
+ return true;
+ }
+
+ @Override
+ public void setReadListener(jakarta.servlet.ReadListener readListener) {}
+ };
+ }
+ };
+ bgRequest.setMethod("PATCH");
+ bgRequest.setRequestURI(location);
+ bgRequest.addHeader(HttpHeader.CONTENT_TYPE, "application/offset+octet-stream");
+ bgRequest.addHeader(HttpHeader.CONTENT_LENGTH, 100);
+ bgRequest.addHeader(HttpHeader.UPLOAD_OFFSET, 0);
+ bgRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+
+ Thread bgThread =
+ new Thread(
+ new Runnable() {
+ @Override
+ public void run() {
+ try {
+ MockHttpServletResponse bgResponse = new MockHttpServletResponse();
+ tusFileUploadService.process(bgRequest, bgResponse, OWNER_KEY);
+ } catch (Exception e) {
+ e.printStackTrace();
+ bgException.set(e);
+ }
+ }
+ });
+ bgThread.start();
+
+ // Wait for the background thread to start reading (meaning it holds the lock)
+ requestStarted.await(2, java.util.concurrent.TimeUnit.SECONDS);
+
+ // 3. Concurrent PATCH request must fail immediately with 423
+ MockHttpServletRequest patchRequest = new MockHttpServletRequest();
+ MockHttpServletResponse patchResponse = new MockHttpServletResponse();
+ patchRequest.setMethod("PATCH");
+ patchRequest.setRequestURI(location);
+ patchRequest.addHeader(HttpHeader.CONTENT_TYPE, "application/offset+octet-stream");
+ patchRequest.addHeader(HttpHeader.CONTENT_LENGTH, 10);
+ patchRequest.addHeader(HttpHeader.UPLOAD_OFFSET, 0);
+ patchRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+ patchRequest.setContent(new byte[10]);
+
+ tusFileUploadService.process(patchRequest, patchResponse, OWNER_KEY);
+ assertThat(patchResponse.getStatus(), is(423));
+
+ // 4. Concurrent HEAD request must interrupt background thread and succeed
+ MockHttpServletRequest headRequest = new MockHttpServletRequest();
+ MockHttpServletResponse headResponse = new MockHttpServletResponse();
+ headRequest.setMethod("HEAD");
+ headRequest.setRequestURI(location);
+ headRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+
+ tusFileUploadService.process(headRequest, headResponse, OWNER_KEY);
+ assertThat(headResponse.getStatus(), is(204));
+ assertThat(headResponse.getHeader(HttpHeader.UPLOAD_OFFSET), is("0"));
+
+ // Clean up
+ bgThread.join(2000);
+ }
+
+ @Test
+ public void testCreationWithUploadOptions() throws Exception {
+ reset();
+ servletRequest.setMethod("OPTIONS");
+ servletRequest.setRequestURI(UPLOAD_URI);
+ tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
+ assertResponseHeader(
+ HttpHeader.TUS_EXTENSION,
+ "creation",
+ "creation-defer-length",
+ "creation-with-upload",
+ "checksum",
+ "checksum-trailer",
+ "termination",
+ "download",
+ "expiration",
+ "concatenation",
+ "concatenation-unfinished");
+ }
+
+ @Test
+ public void testCreationWithUploadSuccess() throws Exception {
+ String uploadContent = "Initial data to upload";
+ byte[] contentBytes = uploadContent.getBytes(StandardCharsets.UTF_8);
+
+ reset();
+ servletRequest.setMethod("POST");
+ servletRequest.setRequestURI(UPLOAD_URI);
+ servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+ servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, contentBytes.length);
+ servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, contentBytes.length);
+ servletRequest.addHeader(HttpHeader.CONTENT_TYPE, "application/offset+octet-stream");
+ servletRequest.setContent(contentBytes);
+
+ tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
+ assertResponseStatus(HttpServletResponse.SC_CREATED);
+ assertResponseHeaderNotBlank(HttpHeader.LOCATION);
+ assertResponseHeader(HttpHeader.UPLOAD_OFFSET, String.valueOf(contentBytes.length));
+
+ String location = servletResponse.getHeader(HttpHeader.LOCATION);
+
+ // Verify content in storage
+ try (InputStream is = tusFileUploadService.getUploadedBytes(location, OWNER_KEY)) {
+ String readContent = IOUtils.toString(is, StandardCharsets.UTF_8);
+ assertThat(readContent, is(uploadContent));
+ }
+ }
+
+ @Test
+ public void testCreationWithUploadDeferredLengthSuccess() throws Exception {
+ String uploadContent = "Initial data to upload with deferred length";
+ byte[] contentBytes = uploadContent.getBytes(StandardCharsets.UTF_8);
+
+ reset();
+ servletRequest.setMethod("POST");
+ servletRequest.setRequestURI(UPLOAD_URI);
+ servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+ servletRequest.addHeader(HttpHeader.UPLOAD_DEFER_LENGTH, "1");
+ servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, contentBytes.length);
+ servletRequest.addHeader(HttpHeader.CONTENT_TYPE, "application/offset+octet-stream");
+ servletRequest.setContent(contentBytes);
+
+ tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
+ assertResponseStatus(HttpServletResponse.SC_CREATED);
+ assertResponseHeaderNotBlank(HttpHeader.LOCATION);
+ assertResponseHeader(HttpHeader.UPLOAD_OFFSET, String.valueOf(contentBytes.length));
+
+ String location =
+ UPLOAD_URI
+ + StringUtils.substringAfter(
+ servletResponse.getHeader(HttpHeader.LOCATION), UPLOAD_URI);
+
+ // Verify content in storage
+ try (InputStream is = tusFileUploadService.getUploadedBytes(location, OWNER_KEY)) {
+ String readContent = IOUtils.toString(is, StandardCharsets.UTF_8);
+ assertThat(readContent, is(uploadContent));
+ }
+ }
+
+ @Test
+ public void testCreationWithUploadInvalidContentType() throws Exception {
+ String uploadContent = "Initial data to upload";
+ byte[] contentBytes = uploadContent.getBytes(StandardCharsets.UTF_8);
+
+ reset();
+ servletRequest.setMethod("POST");
+ servletRequest.setRequestURI(UPLOAD_URI);
+ servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+ servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, contentBytes.length);
+ servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, contentBytes.length);
+ servletRequest.addHeader(HttpHeader.CONTENT_TYPE, "application/octet-stream");
+ servletRequest.setContent(contentBytes);
+
+ tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
+ assertResponseStatus(HttpServletResponse.SC_NOT_ACCEPTABLE);
+ }
+
+ @Test
+ public void testCreationWithUploadExceedsLength() throws Exception {
+ String uploadContent = "Initial data to upload";
+ byte[] contentBytes = uploadContent.getBytes(StandardCharsets.UTF_8);
+
+ reset();
+ servletRequest.setMethod("POST");
+ servletRequest.setRequestURI(UPLOAD_URI);
+ servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+ servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, contentBytes.length - 5);
+ servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, contentBytes.length);
+ servletRequest.addHeader(HttpHeader.CONTENT_TYPE, "application/offset+octet-stream");
+ servletRequest.setContent(contentBytes);
+
+ tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
+ assertResponseStatus(HttpServletResponse.SC_BAD_REQUEST);
+ }
+
+ @Test
+ public void testCreationWithUploadDisabled() throws Exception {
+ String uploadContent = "Initial data to upload";
+ byte[] contentBytes = uploadContent.getBytes(StandardCharsets.UTF_8);
+
+ tusFileUploadService.disableTusExtension("creation-with-upload");
+ try {
+ reset();
+ servletRequest.setMethod("POST");
+ servletRequest.setRequestURI(UPLOAD_URI);
+ servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+ servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, contentBytes.length);
+ servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, contentBytes.length);
+ servletRequest.addHeader(HttpHeader.CONTENT_TYPE, "application/offset+octet-stream");
+ servletRequest.setContent(contentBytes);
+
+ tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
+ assertResponseStatus(HttpServletResponse.SC_BAD_REQUEST);
+ } finally {
+ // Restore for other tests
+ tusFileUploadService = createTusFileUploadService();
+ }
+ }
+
+ @Test
+ public void testCorsHeaders() throws Exception {
+ reset();
+ servletRequest.setMethod("POST");
+ servletRequest.setRequestURI(UPLOAD_URI);
+ servletRequest.addHeader("Origin", "https://example.com");
+ servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+ servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, 100L);
+
+ tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
+ assertResponseHeader("Access-Control-Allow-Origin", "https://example.com");
+ assertResponseHeaderNotBlank("Access-Control-Expose-Headers");
+ }
+
+ @Test
+ public void testCorsPreflight() throws Exception {
+ reset();
+ servletRequest.setMethod("OPTIONS");
+ servletRequest.setRequestURI(UPLOAD_URI);
+ servletRequest.addHeader("Origin", "https://example.com");
+ servletRequest.addHeader("Access-Control-Request-Method", "PATCH");
+
+ tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
+ assertResponseHeader("Access-Control-Allow-Origin", "https://example.com");
+ assertResponseHeader("Access-Control-Allow-Methods", "POST, GET, HEAD, PATCH, DELETE, OPTIONS");
+ assertResponseHeaderNotBlank("Access-Control-Allow-Headers");
+ assertResponseHeader("Access-Control-Max-Age", "86400");
+ }
+
+ @Test
+ public void testTusVersionHeaderOn412() throws Exception {
+ reset();
+ servletRequest.setMethod("POST");
+ servletRequest.setRequestURI(UPLOAD_URI);
+ servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "2.0.0");
+
+ tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
+ assertResponseStatus(HttpServletResponse.SC_PRECONDITION_FAILED);
+ assertResponseHeader(HttpHeader.TUS_VERSION, "1.0.0");
+ }
+
+ @Test
+ public void testModifyUploadLengthOnPatch() throws Exception {
+ // 1. Create upload with deferred length
+ reset();
+ servletRequest.setMethod("POST");
+ servletRequest.setRequestURI(UPLOAD_URI);
+ servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+ servletRequest.addHeader(HttpHeader.UPLOAD_DEFER_LENGTH, "1");
+ tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
+ assertResponseStatus(HttpServletResponse.SC_CREATED);
+ String location = servletResponse.getHeader(HttpHeader.LOCATION);
+
+ // 2. Set length to 100 on PATCH
+ reset();
+ servletRequest.setMethod("PATCH");
+ servletRequest.setRequestURI(location);
+ servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+ servletRequest.addHeader(HttpHeader.UPLOAD_OFFSET, "0");
+ servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, "100");
+ servletRequest.addHeader(HttpHeader.CONTENT_TYPE, "application/offset+octet-stream");
+ servletRequest.setContent("test content".getBytes(StandardCharsets.UTF_8));
+ tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
+ assertResponseStatus(HttpServletResponse.SC_NO_CONTENT);
+
+ // 3. Try to change length to 200 on subsequent PATCH -> should return 400
+ reset();
+ servletRequest.setMethod("PATCH");
+ servletRequest.setRequestURI(location);
+ servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+ servletRequest.addHeader(HttpHeader.UPLOAD_OFFSET, "12");
+ servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, "200");
+ servletRequest.addHeader(HttpHeader.CONTENT_TYPE, "application/offset+octet-stream");
+ servletRequest.setContent("more content".getBytes(StandardCharsets.UTF_8));
+ tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
+ assertResponseStatus(HttpServletResponse.SC_BAD_REQUEST);
+ }
+
+ @Test
+ public void testCreationWithUploadChecksumSuccess() throws Exception {
+ String uploadContent = "Initial data to upload with checksum";
+ byte[] contentBytes = uploadContent.getBytes(StandardCharsets.UTF_8);
+ // Base64 hash for MD5 of "Initial data to upload with checksum"
+ // Base64 of MD5 bytes: aAMsB0BZbWXCBuPWG/ADyA==
+ String base64Checksum = "aAMsB0BZbWXCBuPWG/ADyA==";
+
+ reset();
+ servletRequest.setMethod("POST");
+ servletRequest.setRequestURI(UPLOAD_URI);
+ servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+ servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, contentBytes.length);
+ servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, contentBytes.length);
+ servletRequest.addHeader(HttpHeader.CONTENT_TYPE, "application/offset+octet-stream");
+ servletRequest.addHeader(HttpHeader.UPLOAD_CHECKSUM, "md5 " + base64Checksum);
+ servletRequest.setContent(contentBytes);
+
+ tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
+ assertResponseStatus(HttpServletResponse.SC_CREATED);
+ assertResponseHeader(HttpHeader.UPLOAD_OFFSET, String.valueOf(contentBytes.length));
+ }
+
+ @Test
+ public void testCreationWithUploadChecksumMismatch() throws Exception {
+ String uploadContent = "Initial data to upload with checksum";
+ byte[] contentBytes = uploadContent.getBytes(StandardCharsets.UTF_8);
+ String invalidBase64Checksum = "aAMsB0BZbWXCBuPWG/ADyB=="; // changed A to B
+
+ reset();
+ servletRequest.setMethod("POST");
+ servletRequest.setRequestURI(UPLOAD_URI);
+ servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
+ servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, contentBytes.length);
+ servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, contentBytes.length);
+ servletRequest.addHeader(HttpHeader.CONTENT_TYPE, "application/offset+octet-stream");
+ servletRequest.addHeader(HttpHeader.UPLOAD_CHECKSUM, "md5 " + invalidBase64Checksum);
+ servletRequest.setContent(contentBytes);
+
+ tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
+ assertResponseStatus(460); // Checksum mismatch
+ }
+
+ protected void assertResponseHeader(final String header, final String value) {
+ assertThat(servletResponse.getHeader(header), is(value));
+ }
+
+ protected void assertResponseHeader(final String header, final String... values) {
+ assertThat(
+ Arrays.asList(servletResponse.getHeader(header).split(",")), containsInAnyOrder(values));
+ }
+
+ protected void assertResponseHeaderNotBlank(final String header) {
+ assertTrue(StringUtils.isNotBlank(servletResponse.getHeader(header)));
+ }
+
+ protected void assertResponseHeaderNull(final String header) {
+ assertNull(servletResponse.getHeader(header));
+ }
+
+ protected void assertResponseStatus(final int httpStatus) {
+ assertThat(servletResponse.getStatus(), is(httpStatus));
+ }
+}
diff --git a/src/test/java/me/desair/tus/server/ITRufhProtocol.java b/src/test/java/me/desair/tus/server/ITRufhProtocol.java
new file mode 100644
index 00000000..0a675318
--- /dev/null
+++ b/src/test/java/me/desair/tus/server/ITRufhProtocol.java
@@ -0,0 +1,38 @@
+package me.desair.tus.server;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import org.apache.commons.io.FileUtils;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+
+/**
+ * Disk-backed integration tests for the RUFH (Resumable Uploads for HTTP) protocol implementation.
+ */
+public class ITRufhProtocol extends AbstractITRufhProtocol {
+
+ protected static Path storagePath;
+
+ @BeforeClass
+ public static void setupDataFolder() throws IOException {
+ storagePath = Paths.get("target", "rufh", "data").toAbsolutePath();
+ Files.createDirectories(storagePath);
+ }
+
+ @AfterClass
+ public static void destroyDataFolder() throws IOException {
+ FileUtils.deleteDirectory(storagePath.toFile());
+ }
+
+ @Override
+ protected TusFileUploadService createTusFileUploadService() {
+ return new TusFileUploadService()
+ .withUploadUri(UPLOAD_URI)
+ .withStoragePath(storagePath.toAbsolutePath().toString())
+ .withMaxUploadSize(1073741824L)
+ .withUploadExpirationPeriod(2L * 24 * 60 * 60 * 1000)
+ .withDownloadFeature();
+ }
+}
diff --git a/src/test/java/me/desair/tus/server/ITTusFileUploadService.java b/src/test/java/me/desair/tus/server/ITTusFileUploadService.java
index 53f6025d..f39f6112 100644
--- a/src/test/java/me/desair/tus/server/ITTusFileUploadService.java
+++ b/src/test/java/me/desair/tus/server/ITTusFileUploadService.java
@@ -1,1492 +1,74 @@
package me.desair.tus.server;
-import static me.desair.tus.server.util.MapMatcher.hasSize;
-import static org.hamcrest.CoreMatchers.allOf;
import static org.hamcrest.CoreMatchers.is;
-import static org.hamcrest.CoreMatchers.nullValue;
import static org.hamcrest.MatcherAssert.assertThat;
-import static org.hamcrest.Matchers.containsInAnyOrder;
-import static org.hamcrest.Matchers.greaterThan;
-import static org.hamcrest.collection.IsMapContaining.hasEntry;
import static org.junit.Assert.assertFalse;
-import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
-import static org.junit.Assert.fail;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
-import java.io.InputStream;
-import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
-import java.text.DateFormat;
-import java.text.SimpleDateFormat;
-import java.util.Arrays;
-import java.util.Locale;
-import java.util.UUID;
-import me.desair.tus.server.exception.TusException;
import me.desair.tus.server.upload.UploadInfo;
-import me.desair.tus.server.util.Utils;
import org.apache.commons.io.FileUtils;
-import org.apache.commons.io.IOUtils;
import org.apache.commons.lang3.StringUtils;
import org.junit.AfterClass;
-import org.junit.Before;
import org.junit.BeforeClass;
import org.junit.Test;
-import org.springframework.mock.web.MockHttpServletRequest;
-import org.springframework.mock.web.MockHttpServletResponse;
-/** Test cases for the {@link TusFileUploadService}. */
-public class ITTusFileUploadService {
+/**
+ * Disk-backed integration test suite for {@link TusFileUploadService}. Extends {@link
+ * AbstractITTusFileUploadService} to run all protocol test use cases against disk storage.
+ */
+public class ITTusFileUploadService extends AbstractITTusFileUploadService {
- protected static final String UPLOAD_URI = "/test/upload";
- protected static final String OWNER_KEY = "JOHN_DOE";
-
- private static final DateFormat mockDateFormat =
- new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz", Locale.US);
-
- protected MockHttpServletRequest servletRequest;
- protected MockHttpServletResponse servletResponse;
-
- protected TusFileUploadService tusFileUploadService;
-
- protected static Path storagePath;
-
- @BeforeClass
- public static void setupDataFolder() throws IOException {
- storagePath = Paths.get("target", "tus", "data").toAbsolutePath();
- Files.createDirectories(storagePath);
- }
-
- @AfterClass
- public static void destroyDataFolder() throws IOException {
- FileUtils.deleteDirectory(storagePath.toFile());
- }
-
- @Before
- public void setUp() {
- reset();
- tusFileUploadService =
- new TusFileUploadService()
- .withUploadUri(UPLOAD_URI)
- .withStoragePath(storagePath.toAbsolutePath().toString())
- .withMaxUploadSize(1073741824L)
- .withUploadExpirationPeriod(2L * 24 * 60 * 60 * 1000)
- .withDownloadFeature()
- .withChunkedTransferDecoding(true);
- }
-
- protected void reset() {
- servletRequest = new MockHttpServletRequest();
- servletRequest.setRemoteAddr("192.168.1.1");
- servletRequest.addHeader(HttpHeader.X_FORWARDED_FOR, "10.0.2.1, 123.231.12.4");
- servletResponse = new MockHttpServletResponse();
- }
-
- @Test
- public void testSupportedHttpMethods() {
- assertThat(
- tusFileUploadService.getSupportedHttpMethods(),
- containsInAnyOrder(
- HttpMethod.HEAD,
- HttpMethod.OPTIONS,
- HttpMethod.PATCH,
- HttpMethod.POST,
- HttpMethod.PUT,
- HttpMethod.DELETE,
- HttpMethod.GET));
-
- assertThat(
- tusFileUploadService.getEnabledFeatures(),
- containsInAnyOrder(
- "core",
- "creation",
- "creation-with-upload",
- "checksum",
- "termination",
- "download",
- "expiration",
- "concatenation",
- "cors",
- "resumable-uploads-for-http",
- "http-digests"));
- }
-
- @Test
- public void testDisableFeature() throws Exception {
- tusFileUploadService.disableTusExtension("download");
- tusFileUploadService.disableTusExtension("termination");
- tusFileUploadService.disableTusExtension("resumable-uploads-for-http");
- tusFileUploadService.disableTusExtension("http-digests");
-
- assertThat(
- tusFileUploadService.getSupportedHttpMethods(),
- containsInAnyOrder(HttpMethod.HEAD, HttpMethod.OPTIONS, HttpMethod.PATCH, HttpMethod.POST));
-
- assertThat(
- tusFileUploadService.getEnabledFeatures(),
- containsInAnyOrder(
- "core",
- "creation",
- "creation-with-upload",
- "checksum",
- "expiration",
- "concatenation",
- "cors"));
-
- reset();
- servletRequest.setMethod("GET");
- servletRequest.setRequestURI(UPLOAD_URI + "/" + UUID.randomUUID());
- servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
-
- tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
- assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
- assertResponseStatus(HttpServletResponse.SC_METHOD_NOT_ALLOWED);
-
- reset();
- servletRequest.setMethod("DELETE");
- servletRequest.setRequestURI(UPLOAD_URI + "/" + UUID.randomUUID());
- servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
-
- tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
- assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
- assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
- assertResponseStatus(HttpServletResponse.SC_METHOD_NOT_ALLOWED);
- }
-
- @Test(expected = IllegalArgumentException.class)
- public void testDisableCore() {
- tusFileUploadService.disableTusExtension("core");
- }
-
- @Test(expected = NullPointerException.class)
- public void testWithFileStoreServiceNull() throws Exception {
- tusFileUploadService.withUploadStorageService(null);
- }
-
- @Test
- public void testProcessCompleteUpload() throws Exception {
- String uploadContent = "This is my test upload content";
-
- // Create upload
- servletRequest.setMethod("POST");
- servletRequest.setRequestURI(UPLOAD_URI);
- servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, 0);
- servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, uploadContent.getBytes().length);
- servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
- servletRequest.addHeader(
- HttpHeader.UPLOAD_METADATA, "filename d29ybGRfZG9taW5hdGlvbl9wbGFuLnBkZg==");
-
- tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
- assertResponseHeaderNotBlank(HttpHeader.LOCATION);
- assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
- assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
- assertResponseHeaderNotBlank(HttpHeader.UPLOAD_EXPIRES);
- assertResponseStatus(HttpServletResponse.SC_CREATED);
-
- String location =
- UPLOAD_URI
- + StringUtils.substringAfter(
- servletResponse.getHeader(HttpHeader.LOCATION), UPLOAD_URI);
-
- // Upload bytes
- reset();
- servletRequest.setMethod("PATCH");
- servletRequest.setRequestURI(location);
- servletRequest.addHeader(HttpHeader.CONTENT_TYPE, "application/offset+octet-stream");
- servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, uploadContent.getBytes().length);
- servletRequest.addHeader(HttpHeader.UPLOAD_OFFSET, 0);
- servletRequest.addHeader(HttpHeader.UPLOAD_CHECKSUM, "sha1 Mfhm5HaSPUf+pUakdMxARo4rvfQ=");
- servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
- servletRequest.setContent(uploadContent.getBytes());
-
- tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
- assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
- assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
- assertResponseHeader(HttpHeader.UPLOAD_OFFSET, "" + uploadContent.getBytes().length);
- assertResponseHeaderNotBlank(HttpHeader.UPLOAD_EXPIRES);
- assertResponseStatus(HttpServletResponse.SC_NO_CONTENT);
-
- // Make sure cleanup does not interfere with this test
- tusFileUploadService.cleanup();
-
- // Check with HEAD request upload is complete
- reset();
- servletRequest.setMethod("HEAD");
- servletRequest.setRequestURI(location);
- servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
-
- tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
- assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
- assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
- assertResponseHeader(HttpHeader.UPLOAD_OFFSET, "" + uploadContent.getBytes().length);
- assertResponseHeader(HttpHeader.UPLOAD_LENGTH, "" + uploadContent.getBytes().length);
- assertResponseHeaderNull(HttpHeader.UPLOAD_DEFER_LENGTH);
- assertResponseHeader(
- HttpHeader.UPLOAD_METADATA, "filename d29ybGRfZG9taW5hdGlvbl9wbGFuLnBkZg==");
- assertResponseStatus(HttpServletResponse.SC_NO_CONTENT);
-
- // Get upload info from service
- UploadInfo info = tusFileUploadService.getUploadInfo(location, OWNER_KEY);
- assertFalse(info.isUploadInProgress());
- assertThat(info.getLength(), is((long) uploadContent.getBytes().length));
- assertThat(info.getOffset(), is((long) uploadContent.getBytes().length));
- assertThat(
- info.getMetadata(), allOf(hasSize(1), hasEntry("filename", "world_domination_plan.pdf")));
- assertThat(info.getCreatorIpAddresses(), is("10.0.2.1, 123.231.12.4, 192.168.1.1"));
-
- // Try retrieving the uploaded bytes without owner key
- try {
- tusFileUploadService.getUploadedBytes(location);
- fail();
- } catch (TusException ex) {
- assertThat(ex.getStatus(), is(404));
- }
-
- // Get uploaded bytes from service
- try (InputStream uploadedBytes = tusFileUploadService.getUploadedBytes(location, OWNER_KEY)) {
- assertThat(
- IOUtils.toString(uploadedBytes, StandardCharsets.UTF_8),
- is("This is my test upload content"));
- }
-
- // Make sure cleanup does not interfere with this test
- tusFileUploadService.cleanup();
-
- // Download the upload
- reset();
- servletRequest.setMethod("GET");
- servletRequest.setRequestURI(location);
-
- tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
- assertResponseHeader(HttpHeader.CONTENT_LENGTH, "" + uploadContent.getBytes().length);
- assertResponseHeader(
- HttpHeader.UPLOAD_METADATA, "filename d29ybGRfZG9taW5hdGlvbl9wbGFuLnBkZg==");
- assertResponseStatus(HttpServletResponse.SC_OK);
- assertThat(servletResponse.getContentAsString(), is("This is my test upload content"));
-
- // Pretend that we processed the upload and that we can remove it
- tusFileUploadService.deleteUpload(location, OWNER_KEY);
-
- // Check that the upload is really gone
- reset();
- servletRequest.setMethod("HEAD");
- servletRequest.setRequestURI(location);
- servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
-
- tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
- assertResponseStatus(HttpServletResponse.SC_NOT_FOUND);
- assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
- assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
- }
-
- @Test
- public void testProcessZeroByteUpload() throws Exception {
- // Create upload
- servletRequest.setMethod("POST");
- servletRequest.setRequestURI(UPLOAD_URI);
- servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, 0);
- servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, 0);
- servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
-
- tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
- assertResponseHeaderNotBlank(HttpHeader.LOCATION);
- assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
- assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
- assertResponseHeaderNotBlank(HttpHeader.UPLOAD_EXPIRES);
- assertResponseStatus(HttpServletResponse.SC_CREATED);
-
- String location =
- UPLOAD_URI
- + StringUtils.substringAfter(
- servletResponse.getHeader(HttpHeader.LOCATION), UPLOAD_URI);
-
- // Get upload info from service
- UploadInfo info = tusFileUploadService.getUploadInfo(location, OWNER_KEY);
- assertFalse(info.isUploadInProgress());
- assertThat(info.getLength(), is(0L));
- assertThat(info.getOffset(), is(0L));
-
- // Get uploaded bytes from service
- try (InputStream uploadedBytes = tusFileUploadService.getUploadedBytes(location, OWNER_KEY)) {
- assertThat(IOUtils.toString(uploadedBytes, StandardCharsets.UTF_8), is(""));
- }
-
- // Make sure cleanup does not interfere with this test
- tusFileUploadService.cleanup();
-
- // Download the upload
- reset();
- servletRequest.setMethod("GET");
- servletRequest.setRequestURI(location);
-
- tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
- assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
- assertResponseStatus(HttpServletResponse.SC_OK);
- assertThat(servletResponse.getContentAsString(), is(""));
- }
-
- @Test
- public void testTerminateViaHttpRequest() throws Exception {
- String uploadContent = "This is my terminated test upload";
-
- // Create upload
- servletRequest.setMethod("POST");
- servletRequest.setRequestURI(UPLOAD_URI);
- servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, 0);
- servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, uploadContent.getBytes().length);
- servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
- servletRequest.addHeader(
- HttpHeader.UPLOAD_METADATA, "filename d29ybGRfZG9taW5hdGlvbl9wbGFuLnBkZg==");
-
- tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
- assertResponseHeaderNotBlank(HttpHeader.LOCATION);
- assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
- assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
- assertResponseHeaderNotBlank(HttpHeader.UPLOAD_EXPIRES);
- assertResponseStatus(HttpServletResponse.SC_CREATED);
-
- String location =
- UPLOAD_URI
- + StringUtils.substringAfter(
- servletResponse.getHeader(HttpHeader.LOCATION), UPLOAD_URI);
-
- // Upload bytes
- reset();
- servletRequest.setMethod("PATCH");
- servletRequest.setRequestURI(location);
- servletRequest.addHeader(HttpHeader.CONTENT_TYPE, "application/offset+octet-stream");
- servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, uploadContent.getBytes().length);
- servletRequest.addHeader(HttpHeader.UPLOAD_OFFSET, 0);
- servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
- servletRequest.setContent(uploadContent.getBytes());
-
- tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
- assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
- assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
- assertResponseHeader(HttpHeader.UPLOAD_OFFSET, "" + uploadContent.getBytes().length);
- assertResponseHeaderNotBlank(HttpHeader.UPLOAD_EXPIRES);
- assertResponseStatus(HttpServletResponse.SC_NO_CONTENT);
-
- // Make sure cleanup does not interfere with this test
- tusFileUploadService.cleanup();
-
- // Download the upload to make sure it was uploaded correctly
- reset();
- servletRequest.setMethod("GET");
- servletRequest.setRequestURI(location);
-
- tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
- assertResponseHeader(HttpHeader.CONTENT_LENGTH, "" + uploadContent.getBytes().length);
- assertResponseHeader(
- HttpHeader.UPLOAD_METADATA, "filename d29ybGRfZG9taW5hdGlvbl9wbGFuLnBkZg==");
- assertResponseStatus(HttpServletResponse.SC_OK);
- assertThat(servletResponse.getContentAsString(), is("This is my terminated test upload"));
-
- // Terminate the upload so that the server can remove it
- reset();
- servletRequest.setMethod("DELETE");
- servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
- servletRequest.setRequestURI(location);
-
- tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
- assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
- assertResponseStatus(HttpServletResponse.SC_NO_CONTENT);
-
- // Check that the upload is really gone
- reset();
- servletRequest.setMethod("HEAD");
- servletRequest.setRequestURI(location);
- servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
-
- tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
- assertResponseStatus(HttpServletResponse.SC_NOT_FOUND);
- assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
- assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
- }
-
- @Test
- public void testProcessUploadTwoParts() throws Exception {
- String part1 =
- "29\r\nThis is the first part of my test upload "
- + "\r\n0\r\nUpload-Checksum: sha1 n5RQbRwM6UVAD+9iuHEmnN6HCGQ=";
- String part2 =
- "1C\r\nand this is the second part."
- + "\r\n0\r\nUpload-Checksum: sha1 oNge323kGFKICxp+Me5xJgPvGEM=";
-
- // Create upload
- servletRequest.setMethod("POST");
- servletRequest.setRequestURI(UPLOAD_URI);
- servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, 0);
- servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, "69");
- servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
- servletRequest.addHeader(
- HttpHeader.UPLOAD_METADATA, "filename d29ybGRfZG9taW5hdGlvbl9wbGFuLnBkZg==");
-
- tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
- assertResponseHeaderNotBlank(HttpHeader.LOCATION);
- assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
- assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
- assertResponseHeaderNotBlank(HttpHeader.UPLOAD_EXPIRES);
- assertResponseStatus(HttpServletResponse.SC_CREATED);
-
- String location =
- UPLOAD_URI
- + StringUtils.substringAfter(
- servletResponse.getHeader(HttpHeader.LOCATION), UPLOAD_URI);
-
- // Make sure cleanup does not interfere with this test
- tusFileUploadService.cleanup();
-
- // Upload part 1 bytes
- reset();
- servletRequest.setMethod("PATCH");
- servletRequest.setRequestURI(location);
- servletRequest.addHeader(HttpHeader.CONTENT_TYPE, "application/offset+octet-stream");
- servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, "41");
- servletRequest.addHeader(HttpHeader.UPLOAD_OFFSET, 0);
- servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
- servletRequest.addHeader(HttpHeader.TRANSFER_ENCODING, "chunked");
- servletRequest.setContent(part1.getBytes());
-
- tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
- assertResponseStatus(HttpServletResponse.SC_NO_CONTENT);
- assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
- assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
- assertResponseHeaderNotBlank(HttpHeader.UPLOAD_EXPIRES);
- assertResponseHeader(HttpHeader.UPLOAD_OFFSET, "41");
-
- // Check with service that upload is still in progress
- UploadInfo info = tusFileUploadService.getUploadInfo(location, OWNER_KEY);
- assertTrue(info.isUploadInProgress());
- assertThat(info.getLength(), is(69L));
- assertThat(info.getOffset(), is(41L));
- assertThat(
- info.getMetadata(), allOf(hasSize(1), hasEntry("filename", "world_domination_plan.pdf")));
- assertThat(info.getCreatorIpAddresses(), is("10.0.2.1, 123.231.12.4, 192.168.1.1"));
-
- // Make sure cleanup does not interfere with this test
- tusFileUploadService.cleanup();
-
- // Verify that we cannot download an in-progress upload
- reset();
- servletRequest.setMethod("GET");
- servletRequest.setRequestURI(location);
-
- tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
- assertResponseStatus(204);
- assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
- assertThat(servletResponse.getContentAsString(), is(""));
-
- // Upload part 2 bytes
- reset();
- servletRequest.setMethod("PATCH");
- servletRequest.setRequestURI(location);
- servletRequest.addHeader(HttpHeader.CONTENT_TYPE, "application/offset+octet-stream");
- servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, "28");
- servletRequest.addHeader(HttpHeader.UPLOAD_OFFSET, "41");
- servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
- servletRequest.addHeader(HttpHeader.TRANSFER_ENCODING, "chunked");
- servletRequest.setContent(part2.getBytes());
-
- tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
- assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
- assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
- assertResponseHeader(HttpHeader.UPLOAD_OFFSET, "69");
- assertResponseHeaderNotBlank(HttpHeader.UPLOAD_EXPIRES);
- assertResponseStatus(HttpServletResponse.SC_NO_CONTENT);
-
- // Check with HEAD request upload is complete
- reset();
- servletRequest.setMethod("HEAD");
- servletRequest.setRequestURI(location);
- servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
-
- tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
- assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
- assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
- assertResponseHeader(HttpHeader.UPLOAD_OFFSET, "69");
- assertResponseHeader(HttpHeader.UPLOAD_LENGTH, "69");
- assertResponseHeaderNull(HttpHeader.UPLOAD_DEFER_LENGTH);
- assertResponseHeader(
- HttpHeader.UPLOAD_METADATA, "filename d29ybGRfZG9taW5hdGlvbl9wbGFuLnBkZg==");
- assertResponseStatus(HttpServletResponse.SC_NO_CONTENT);
-
- // Get upload info from service
- info = tusFileUploadService.getUploadInfo(location, OWNER_KEY);
- assertFalse(info.isUploadInProgress());
- assertThat(info.getLength(), is(69L));
- assertThat(info.getOffset(), is(69L));
- assertThat(
- info.getMetadata(), allOf(hasSize(1), hasEntry("filename", "world_domination_plan.pdf")));
- assertThat(info.getCreatorIpAddresses(), is("10.0.2.1, 123.231.12.4, 192.168.1.1"));
-
- // Get uploaded bytes from service
- try (InputStream uploadedBytes = tusFileUploadService.getUploadedBytes(location, OWNER_KEY)) {
- assertThat(
- IOUtils.toString(uploadedBytes, StandardCharsets.UTF_8),
- is("This is the first part of my test upload and this is the second part."));
- }
- }
-
- @Test
- public void testProcessUploadDeferredLength() throws Exception {
- String part1 = "When sending this part, we don't know the length and ";
- String part2 = "when sending this part, we know the length but the upload is not complete. ";
- String part3 = "Finally when sending the third part, the upload is complete.";
-
- // Create upload
- servletRequest.setMethod("POST");
- servletRequest.setRequestURI(UPLOAD_URI);
- servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, 0);
- servletRequest.addHeader(HttpHeader.UPLOAD_DEFER_LENGTH, 1);
- servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
- servletRequest.addHeader(
- HttpHeader.UPLOAD_METADATA, "filename d29ybGRfZG9taW5hdGlvbl9wbGFuLnBkZg==");
-
- tusFileUploadService.process(servletRequest, servletResponse);
- assertResponseHeaderNotBlank(HttpHeader.LOCATION);
- assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
- assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
- assertResponseHeaderNotBlank(HttpHeader.UPLOAD_EXPIRES);
- assertResponseStatus(HttpServletResponse.SC_CREATED);
-
- Long expirationTimestampBefore =
- Long.parseLong(
- String.valueOf(
- mockDateFormat
- .parse(servletResponse.getHeader(HttpHeader.UPLOAD_EXPIRES))
- .getTime()));
-
- String location =
- UPLOAD_URI
- + StringUtils.substringAfter(
- servletResponse.getHeader(HttpHeader.LOCATION), UPLOAD_URI);
-
- // Upload part 1 bytes
- reset();
- servletRequest.setMethod("PATCH");
- servletRequest.setRequestURI(location);
- servletRequest.addHeader(HttpHeader.CONTENT_TYPE, "application/offset+octet-stream");
- servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, part1.getBytes().length);
- servletRequest.addHeader(HttpHeader.UPLOAD_OFFSET, 0);
- servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
- servletRequest.setContent(part1.getBytes());
-
- tusFileUploadService.process(servletRequest, servletResponse);
- assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
- assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
- assertResponseHeaderNotBlank(HttpHeader.UPLOAD_EXPIRES);
- assertResponseHeader(HttpHeader.UPLOAD_OFFSET, "" + part1.getBytes().length);
- assertResponseStatus(HttpServletResponse.SC_NO_CONTENT);
-
- // Check with service that upload is still in progress
- UploadInfo info = tusFileUploadService.getUploadInfo(location, null);
- assertTrue(info.isUploadInProgress());
- assertThat(info.getLength(), is(nullValue()));
- assertThat(info.getOffset(), is((long) part1.getBytes().length));
- assertThat(
- info.getMetadata(), allOf(hasSize(1), hasEntry("filename", "world_domination_plan.pdf")));
- assertThat(info.getCreatorIpAddresses(), is("10.0.2.1, 123.231.12.4, 192.168.1.1"));
-
- // Make sure cleanup does not interfere with this test
- tusFileUploadService.cleanup();
-
- // Check with HEAD request length is still not known
- reset();
- servletRequest.setMethod("HEAD");
- servletRequest.setRequestURI(location);
- servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
-
- tusFileUploadService.process(servletRequest, servletResponse);
- assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
- assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
- assertResponseHeader(HttpHeader.UPLOAD_OFFSET, "" + part1.getBytes().length);
- assertResponseHeader(HttpHeader.UPLOAD_DEFER_LENGTH, "1");
- assertResponseHeader(
- HttpHeader.UPLOAD_METADATA, "filename d29ybGRfZG9taW5hdGlvbl9wbGFuLnBkZg==");
- assertResponseStatus(HttpServletResponse.SC_NO_CONTENT);
-
- // Upload part 2 bytes with length
- reset();
- servletRequest.setMethod("PATCH");
- servletRequest.setRequestURI(location);
- servletRequest.addHeader(HttpHeader.CONTENT_TYPE, "application/offset+octet-stream");
- servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, part2.getBytes().length);
- servletRequest.addHeader(HttpHeader.UPLOAD_OFFSET, part1.getBytes().length);
- servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, (part1 + part2 + part3).getBytes().length);
- servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
- servletRequest.setContent(part2.getBytes());
-
- tusFileUploadService.process(servletRequest, servletResponse);
- assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
- assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
- assertResponseHeader(HttpHeader.UPLOAD_OFFSET, "" + (part1 + part2).getBytes().length);
- assertResponseHeaderNotBlank(HttpHeader.UPLOAD_EXPIRES);
- assertResponseStatus(HttpServletResponse.SC_NO_CONTENT);
-
- // Make sure cleanup does not interfere with this test
- tusFileUploadService.cleanup();
-
- // Check with HEAD request length is known
- reset();
- servletRequest.setMethod("HEAD");
- servletRequest.setRequestURI(location);
- servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
-
- tusFileUploadService.process(servletRequest, servletResponse);
- assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
- assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
- assertResponseHeader(HttpHeader.UPLOAD_OFFSET, "" + (part1 + part2).getBytes().length);
- assertResponseHeader(HttpHeader.UPLOAD_LENGTH, "" + (part1 + part2 + part3).getBytes().length);
- assertResponseHeader(
- HttpHeader.UPLOAD_METADATA, "filename d29ybGRfZG9taW5hdGlvbl9wbGFuLnBkZg==");
- assertResponseHeaderNull(HttpHeader.UPLOAD_DEFER_LENGTH);
- assertResponseStatus(HttpServletResponse.SC_NO_CONTENT);
-
- info = tusFileUploadService.getUploadInfo(location, null);
- assertTrue(info.isUploadInProgress());
- assertThat(info.getLength(), is((long) (part1 + part2 + part3).getBytes().length));
-
- // check that expiration timestamp was updated
- assertThat(info.getExpirationTimestamp(), greaterThan(expirationTimestampBefore));
-
- // Upload part 3 bytes
- reset();
- servletRequest.setMethod("PATCH");
- servletRequest.setRequestURI(location);
- servletRequest.addHeader(HttpHeader.CONTENT_TYPE, "application/offset+octet-stream");
- servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, part3.getBytes().length);
- servletRequest.addHeader(HttpHeader.UPLOAD_OFFSET, (part1 + part2).getBytes().length);
- servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
- servletRequest.setContent(part3.getBytes());
-
- tusFileUploadService.process(servletRequest, servletResponse);
- assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
- assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
- assertResponseHeader(HttpHeader.UPLOAD_OFFSET, "" + (part1 + part2 + part3).getBytes().length);
- assertResponseHeaderNotBlank(HttpHeader.UPLOAD_EXPIRES);
- assertResponseStatus(HttpServletResponse.SC_NO_CONTENT);
-
- // Make sure cleanup does not interfere with this test
- tusFileUploadService.cleanup();
-
- // Get upload info from service
- info = tusFileUploadService.getUploadInfo(location, null);
- assertFalse(info.isUploadInProgress());
- assertThat(info.getLength(), is((long) (part1 + part2 + part3).getBytes().length));
- assertThat(info.getOffset(), is((long) (part1 + part2 + part3).getBytes().length));
- assertThat(
- info.getMetadata(), allOf(hasSize(1), hasEntry("filename", "world_domination_plan.pdf")));
-
- // Get uploaded bytes from service
- try (InputStream uploadedBytes = tusFileUploadService.getUploadedBytes(location, null)) {
- assertThat(
- IOUtils.toString(uploadedBytes, StandardCharsets.UTF_8),
- is(
- "When sending this part, we don't know the length and "
- + "when sending this part, we know the length but the upload is not complete. "
- + "Finally when sending the third part, the upload is complete."));
- }
-
- // Make sure cleanup does not interfere with this test
- tusFileUploadService.cleanup();
-
- // Download the upload
- reset();
- servletRequest.setMethod("GET");
- servletRequest.setRequestURI(location);
-
- tusFileUploadService.process(servletRequest, servletResponse, null);
- assertResponseStatus(HttpServletResponse.SC_OK);
- assertResponseHeader(HttpHeader.CONTENT_LENGTH, "" + (part1 + part2 + part3).getBytes().length);
- assertResponseHeader(
- HttpHeader.UPLOAD_METADATA, "filename d29ybGRfZG9taW5hdGlvbl9wbGFuLnBkZg==");
- assertThat(
- servletResponse.getContentAsString(),
- is(
- "When sending this part, we don't know the length and "
- + "when sending this part, we know the length but the upload is not complete. "
- + "Finally when sending the third part, the upload is complete."));
- }
-
- @Test
- public void testProcessUploadInvalidChecksumSecondPart() throws Exception {
- String part1 =
- "29\r\nThis is the first part of my test upload "
- + "\r\n0\r\nUPLOAD-CHECKSUM: sha1 n5RQbRwM6UVAD+9iuHEmnN6HCGQ=";
- String part2 = "1C\r\nand this is the second part." + "\r\n0\r\nupload-checksum: sha1 invalid";
-
- // Create upload
- servletRequest.setMethod("POST");
- servletRequest.setRequestURI(UPLOAD_URI);
- servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, 0);
- servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, "69");
- servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
- servletRequest.addHeader(
- HttpHeader.UPLOAD_METADATA, "filename d29ybGRfZG9taW5hdGlvbl9wbGFuLnBkZg==");
-
- tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
- assertResponseHeaderNotBlank(HttpHeader.LOCATION);
- assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
- assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
- assertResponseHeaderNotBlank(HttpHeader.UPLOAD_EXPIRES);
- assertResponseStatus(HttpServletResponse.SC_CREATED);
-
- String location =
- UPLOAD_URI
- + StringUtils.substringAfter(
- servletResponse.getHeader(HttpHeader.LOCATION), UPLOAD_URI);
-
- // Make sure cleanup does not interfere with this test
- tusFileUploadService.cleanup();
-
- // Upload part 1 bytes
- reset();
- servletRequest.setMethod("PATCH");
- servletRequest.setRequestURI(location);
- servletRequest.addHeader(HttpHeader.CONTENT_TYPE, "application/offset+octet-stream");
- servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, "41");
- servletRequest.addHeader(HttpHeader.UPLOAD_OFFSET, 0);
- servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
- servletRequest.addHeader(HttpHeader.TRANSFER_ENCODING, "chunked");
- servletRequest.setContent(part1.getBytes());
-
- tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
- assertResponseStatus(HttpServletResponse.SC_NO_CONTENT);
- assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
- assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
- assertResponseHeaderNotBlank(HttpHeader.UPLOAD_EXPIRES);
- assertResponseHeader(HttpHeader.UPLOAD_OFFSET, "41");
-
- Long expirationTimestampBefore =
- Long.parseLong(
- String.valueOf(
- mockDateFormat
- .parse(servletResponse.getHeader(HttpHeader.UPLOAD_EXPIRES))
- .getTime()));
-
- // Make sure cleanup does not interfere with this test
- tusFileUploadService.cleanup();
-
- // Upload part 2 bytes
- reset();
- servletRequest.setMethod("PATCH");
- servletRequest.setRequestURI(location);
- servletRequest.addHeader(HttpHeader.CONTENT_TYPE, "application/offset+octet-stream");
- servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, "28");
- servletRequest.addHeader(HttpHeader.UPLOAD_OFFSET, "41");
- servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
- servletRequest.addHeader(HttpHeader.TRANSFER_ENCODING, "chunked");
- servletRequest.setContent(part2.getBytes());
-
- tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
-
- // We expect the server to return a checksum mismatch error
- assertResponseStatus(460);
- assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
- assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
-
- // Check that upload info is still from the first patch
- UploadInfo info = tusFileUploadService.getUploadInfo(location, OWNER_KEY);
- assertTrue(info.isUploadInProgress());
- assertThat(info.getLength(), is(69L));
- assertThat(info.getOffset(), is(41L));
- assertThat(
- info.getMetadata(), allOf(hasSize(1), hasEntry("filename", "world_domination_plan.pdf")));
-
- // check that expiration timestamp was updated
- assertThat(info.getExpirationTimestamp(), greaterThan(expirationTimestampBefore));
-
- // We only stored the first valid part
- try (InputStream uploadedBytes = tusFileUploadService.getUploadedBytes(location, OWNER_KEY)) {
- assertThat(
- IOUtils.toString(uploadedBytes, StandardCharsets.UTF_8),
- is("This is the first part of my test upload "));
- }
-
- // Make sure cleanup does not interfere with this test
- tusFileUploadService.cleanup();
-
- // Terminate our in progress upload
- reset();
- servletRequest.setMethod("DELETE");
- servletRequest.setRequestURI(location);
- servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
-
- tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
-
- // We expect the server to return a no content code to indicate successful deletion
- assertResponseStatus(HttpServletResponse.SC_NO_CONTENT);
- assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
- assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
-
- // Make sure cleanup does not interfere with this test
- tusFileUploadService.cleanup();
-
- // Check that the upload is really gone
- reset();
- servletRequest.setMethod("HEAD");
- servletRequest.setRequestURI(location);
- servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
-
- tusFileUploadService.process(servletRequest, servletResponse);
- assertResponseStatus(HttpServletResponse.SC_NOT_FOUND);
- assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
- assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
- }
-
- @Test
- public void testCleanupExpiredUpload() throws Exception {
- // Set the expiration period to 500 ms
- tusFileUploadService.withUploadExpirationPeriod(500L);
-
- String part1 = "This is the first part of my test upload";
- // Create upload
- servletRequest.setMethod("POST");
- servletRequest.setRequestURI(UPLOAD_URI);
- servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, 0);
- servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, part1.getBytes().length + 20L);
- servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
-
- tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
- assertResponseHeaderNotBlank(HttpHeader.LOCATION);
- assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
- assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
- assertResponseHeaderNotBlank(HttpHeader.UPLOAD_EXPIRES);
- assertResponseStatus(HttpServletResponse.SC_CREATED);
-
- String location =
- UPLOAD_URI
- + StringUtils.substringAfter(
- servletResponse.getHeader(HttpHeader.LOCATION), UPLOAD_URI);
-
- // Upload part 1 bytes
- reset();
- servletRequest.setMethod("PATCH");
- servletRequest.setRequestURI(location);
- servletRequest.addHeader(HttpHeader.CONTENT_TYPE, "application/offset+octet-stream");
- servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, part1.getBytes().length);
- servletRequest.addHeader(HttpHeader.UPLOAD_OFFSET, 0);
- servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
- servletRequest.setContent(part1.getBytes());
-
- tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
- assertResponseStatus(HttpServletResponse.SC_NO_CONTENT);
- assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
- assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
- assertResponseHeaderNotBlank(HttpHeader.UPLOAD_EXPIRES);
- assertResponseHeader(HttpHeader.UPLOAD_OFFSET, "" + part1.getBytes().length);
-
- // Check with service that upload is still in progress
- UploadInfo info = tusFileUploadService.getUploadInfo(location, OWNER_KEY);
- assertTrue(info.isUploadInProgress());
- assertThat(info.getLength(), is(part1.getBytes().length + 20L));
- assertThat(info.getOffset(), is(Long.valueOf(part1.getBytes().length)));
-
- // Now wait until the upload expired and run the cleanup
- Utils.sleep(1000L);
- tusFileUploadService.cleanup();
-
- // Check with HEAD request that the upload is gone
- // If a Client does attempt to resume an upload which has since been removed by the Server,
- // the Server SHOULD respond with the404 Not Found or 410 Gone status.
- reset();
- servletRequest.setMethod("HEAD");
- servletRequest.setRequestURI(location);
- servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
-
- tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
- assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
- assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
- assertResponseStatus(HttpServletResponse.SC_NOT_FOUND);
- }
-
- @Test
- public void testConcatenationCompleted() throws Exception {
- String part1 =
- "29\r\nThis is the first part of my test upload "
- + "\r\n0\r\nUpload-Checksum: sha1 n5RQbRwM6UVAD+9iuHEmnN6HCGQ=";
- String part2 =
- "1C\r\nand this is the second part."
- + "\r\n0\r\nUpload-Checksum: sha1 oNge323kGFKICxp+Me5xJgPvGEM=";
-
- // Create first upload
- servletRequest.setMethod("POST");
- servletRequest.setRequestURI(UPLOAD_URI);
- servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, 0);
- servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, "41");
- servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
- servletRequest.addHeader(HttpHeader.UPLOAD_CONCAT, "partial");
- servletRequest.addHeader(
- HttpHeader.UPLOAD_METADATA, "filename d29ybGRfZG9taW5hdGlvbl9wbGFuLnBkZg==");
-
- tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
- assertResponseHeaderNotBlank(HttpHeader.LOCATION);
- assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
- assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
- assertResponseHeaderNotBlank(HttpHeader.UPLOAD_EXPIRES);
- assertResponseStatus(HttpServletResponse.SC_CREATED);
-
- String location1 =
- UPLOAD_URI
- + StringUtils.substringAfter(
- servletResponse.getHeader(HttpHeader.LOCATION), UPLOAD_URI);
-
- // Make sure cleanup does not interfere with this test
- tusFileUploadService.cleanup();
-
- // Upload part 1 bytes
- reset();
- servletRequest.setMethod("PATCH");
- servletRequest.setRequestURI(location1);
- servletRequest.addHeader(HttpHeader.CONTENT_TYPE, "application/offset+octet-stream");
- servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, "41");
- servletRequest.addHeader(HttpHeader.UPLOAD_OFFSET, 0);
- servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
- servletRequest.addHeader(HttpHeader.TRANSFER_ENCODING, "chunked");
- servletRequest.setContent(part1.getBytes());
-
- tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
- assertResponseStatus(HttpServletResponse.SC_NO_CONTENT);
- assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
- assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
- assertResponseHeaderNotBlank(HttpHeader.UPLOAD_EXPIRES);
- assertResponseHeader(HttpHeader.UPLOAD_OFFSET, "41");
-
- // Make sure cleanup does not interfere with this test
- tusFileUploadService.cleanup();
-
- // Create the second upload
- reset();
- servletRequest.setMethod("POST");
- servletRequest.setRequestURI(UPLOAD_URI);
- servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, 0);
- servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, "28");
- servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
- servletRequest.addHeader(HttpHeader.UPLOAD_CONCAT, "partial");
- servletRequest.addHeader(
- HttpHeader.UPLOAD_METADATA, "filename d29ybGRfZG9taW5hdGlvbl9wbGFuLnBkZg==");
-
- tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
- assertResponseHeaderNotBlank(HttpHeader.LOCATION);
- assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
- assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
- assertResponseHeaderNotBlank(HttpHeader.UPLOAD_EXPIRES);
- assertResponseStatus(HttpServletResponse.SC_CREATED);
-
- String location2 =
- UPLOAD_URI
- + StringUtils.substringAfter(
- servletResponse.getHeader(HttpHeader.LOCATION), UPLOAD_URI);
-
- // Upload part 2 bytes
- reset();
- servletRequest.setMethod("PATCH");
- servletRequest.setRequestURI(location2);
- servletRequest.addHeader(HttpHeader.CONTENT_TYPE, "application/offset+octet-stream");
- servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, "28");
- servletRequest.addHeader(HttpHeader.UPLOAD_OFFSET, "0");
- servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
- servletRequest.addHeader(HttpHeader.TRANSFER_ENCODING, "chunked");
- servletRequest.setContent(part2.getBytes());
-
- tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
- assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
- assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
- assertResponseHeader(HttpHeader.UPLOAD_OFFSET, "28");
- assertResponseHeaderNotBlank(HttpHeader.UPLOAD_EXPIRES);
- assertResponseStatus(HttpServletResponse.SC_NO_CONTENT);
-
- // Create the final concatenated upload
- reset();
- servletRequest.setMethod("POST");
- servletRequest.setRequestURI(UPLOAD_URI);
- servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, 0);
- servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
- servletRequest.addHeader(HttpHeader.UPLOAD_CONCAT, "final ; " + location1 + " " + location2);
- servletRequest.addHeader(
- HttpHeader.UPLOAD_METADATA,
- "filename d29ybGRfZG9taW5hdGlvbl9tYXBfY29uY2F0ZW5hdGVkLnBkZg==");
-
- tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
- assertResponseHeaderNotBlank(HttpHeader.LOCATION);
- assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
- assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
- assertResponseHeaderNotBlank(HttpHeader.UPLOAD_EXPIRES);
- assertResponseStatus(HttpServletResponse.SC_CREATED);
-
- String location =
- UPLOAD_URI
- + StringUtils.substringAfter(
- servletResponse.getHeader(HttpHeader.LOCATION), UPLOAD_URI);
-
- // Check with HEAD request upload is complete
- reset();
- servletRequest.setMethod("HEAD");
- servletRequest.setRequestURI(location);
- servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
-
- tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
- assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
- assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
- assertResponseHeader(HttpHeader.UPLOAD_OFFSET, "69");
- assertResponseHeader(HttpHeader.UPLOAD_LENGTH, "69");
- assertResponseHeader(HttpHeader.UPLOAD_CONCAT, "final ; " + location1 + " " + location2);
- assertResponseHeaderNull(HttpHeader.UPLOAD_DEFER_LENGTH);
- assertResponseHeader(
- HttpHeader.UPLOAD_METADATA,
- "filename d29ybGRfZG9taW5hdGlvbl9tYXBfY29uY2F0ZW5hdGVkLnBkZg==");
- assertResponseStatus(HttpServletResponse.SC_NO_CONTENT);
-
- // Get upload info from service
- UploadInfo info = tusFileUploadService.getUploadInfo(location, OWNER_KEY);
- assertFalse(info.isUploadInProgress());
- assertThat(info.getLength(), is(69L));
- assertThat(info.getOffset(), is(69L));
- assertThat(info.isUploadInProgress(), is(false));
- assertThat(
- info.getMetadata(),
- allOf(hasSize(1), hasEntry("filename", "world_domination_map_concatenated.pdf")));
- assertThat(info.getCreatorIpAddresses(), is("10.0.2.1, 123.231.12.4, 192.168.1.1"));
-
- // Download the upload
- reset();
- servletRequest.setMethod("GET");
- servletRequest.setRequestURI(location);
-
- tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
- assertResponseStatus(HttpServletResponse.SC_OK);
- assertResponseHeader(HttpHeader.CONTENT_LENGTH, "69");
- assertResponseHeader(
- HttpHeader.UPLOAD_METADATA,
- "filename d29ybGRfZG9taW5hdGlvbl9tYXBfY29uY2F0ZW5hdGVkLnBkZg==");
- assertThat(
- servletResponse.getContentAsString(),
- is("This is the first part of my test upload and this is the second part."));
-
- // Get uploaded bytes from service
- try (InputStream uploadedBytes = tusFileUploadService.getUploadedBytes(location, OWNER_KEY)) {
- assertThat(
- IOUtils.toString(uploadedBytes, StandardCharsets.UTF_8),
- is("This is the first part of my test upload and this is the second part."));
- }
- }
-
- @Test
- public void testConcatenationUnfinished() throws Exception {
- String part1 = "When sending this part, the final upload was already created. ";
- String part2 = "This is the second part of our concatenated upload. ";
- String part3 = "Finally when sending the third part, the final upload is complete.";
-
- // Create upload part 1
- servletRequest.setMethod("POST");
- servletRequest.setRequestURI(UPLOAD_URI);
- servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, 0);
- servletRequest.addHeader(HttpHeader.UPLOAD_DEFER_LENGTH, 1);
- servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
- servletRequest.addHeader(HttpHeader.UPLOAD_CONCAT, "partial");
- servletRequest.addHeader(HttpHeader.UPLOAD_METADATA, "filename cGFydDEucGRm");
-
- tusFileUploadService.process(servletRequest, servletResponse);
- assertResponseHeaderNotBlank(HttpHeader.LOCATION);
- assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
- assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
- assertResponseHeaderNotBlank(HttpHeader.UPLOAD_EXPIRES);
- assertResponseStatus(HttpServletResponse.SC_CREATED);
-
- String location1 =
- UPLOAD_URI
- + StringUtils.substringAfter(
- servletResponse.getHeader(HttpHeader.LOCATION), UPLOAD_URI);
-
- reset();
- // Create upload part 2
- servletRequest.setMethod("POST");
- servletRequest.setRequestURI(UPLOAD_URI);
- servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, 0);
- servletRequest.addHeader(HttpHeader.UPLOAD_DEFER_LENGTH, 1);
- servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
- servletRequest.addHeader(HttpHeader.UPLOAD_CONCAT, "partial");
- servletRequest.addHeader(HttpHeader.UPLOAD_METADATA, "filename cGFydDIucGRm");
-
- tusFileUploadService.process(servletRequest, servletResponse);
- assertResponseHeaderNotBlank(HttpHeader.LOCATION);
- assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
- assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
- assertResponseHeaderNotBlank(HttpHeader.UPLOAD_EXPIRES);
- assertResponseStatus(HttpServletResponse.SC_CREATED);
-
- String location2 =
- UPLOAD_URI
- + StringUtils.substringAfter(
- servletResponse.getHeader(HttpHeader.LOCATION), UPLOAD_URI);
-
- reset();
- // Create upload part 3
- servletRequest.setMethod("POST");
- servletRequest.setRequestURI(UPLOAD_URI);
- servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, 0);
- servletRequest.addHeader(HttpHeader.UPLOAD_DEFER_LENGTH, 1);
- servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
- servletRequest.addHeader(HttpHeader.UPLOAD_CONCAT, "partial");
- servletRequest.addHeader(HttpHeader.UPLOAD_METADATA, "filename cGFydDMucGRm");
-
- tusFileUploadService.process(servletRequest, servletResponse);
- assertResponseHeaderNotBlank(HttpHeader.LOCATION);
- assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
- assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
- assertResponseHeaderNotBlank(HttpHeader.UPLOAD_EXPIRES);
- assertResponseStatus(HttpServletResponse.SC_CREATED);
-
- String location3 =
- UPLOAD_URI
- + StringUtils.substringAfter(
- servletResponse.getHeader(HttpHeader.LOCATION), UPLOAD_URI);
-
- // Upload part 2 bytes
- reset();
- servletRequest.setMethod("PATCH");
- servletRequest.setRequestURI(location2);
- servletRequest.addHeader(HttpHeader.CONTENT_TYPE, "application/offset+octet-stream");
- servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, part2.getBytes().length);
- servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, part2.getBytes().length);
- servletRequest.addHeader(HttpHeader.UPLOAD_OFFSET, 0);
- servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
- servletRequest.setContent(part2.getBytes());
-
- tusFileUploadService.process(servletRequest, servletResponse);
- assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
- assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
- assertResponseHeaderNotBlank(HttpHeader.UPLOAD_EXPIRES);
- assertResponseHeader(HttpHeader.UPLOAD_OFFSET, "" + part2.getBytes().length);
- assertResponseStatus(HttpServletResponse.SC_NO_CONTENT);
-
- reset();
- // Create final upload
- servletRequest.setMethod("POST");
- servletRequest.setRequestURI(UPLOAD_URI);
- servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, 0);
- servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
- servletRequest.addHeader(
- HttpHeader.UPLOAD_CONCAT, "final;" + location1 + " " + location2 + " " + location3);
- servletRequest.addHeader(HttpHeader.UPLOAD_METADATA, "filename ZmluYWwucGRm");
-
- tusFileUploadService.process(servletRequest, servletResponse);
- assertResponseHeaderNotBlank(HttpHeader.LOCATION);
- assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
- assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
- assertResponseHeaderNotBlank(HttpHeader.UPLOAD_EXPIRES);
- assertResponseStatus(HttpServletResponse.SC_CREATED);
-
- String locationFinal =
- UPLOAD_URI
- + StringUtils.substringAfter(
- servletResponse.getHeader(HttpHeader.LOCATION), UPLOAD_URI);
-
- // Check with HEAD request that length of final upload is undefined
- reset();
- servletRequest.setMethod("HEAD");
- servletRequest.setRequestURI(locationFinal);
- servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
-
- tusFileUploadService.process(servletRequest, servletResponse);
- assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
- assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
- assertResponseHeaderNull(HttpHeader.UPLOAD_OFFSET);
- assertResponseHeaderNull(HttpHeader.UPLOAD_LENGTH);
- assertResponseHeader(HttpHeader.UPLOAD_METADATA, "filename ZmluYWwucGRm");
- assertResponseHeader(
- HttpHeader.UPLOAD_CONCAT, "final;" + location1 + " " + location2 + " " + location3);
- assertResponseHeaderNull(HttpHeader.UPLOAD_DEFER_LENGTH);
- assertResponseStatus(HttpServletResponse.SC_NO_CONTENT);
-
- // Verify that we cannot download an unfinished final upload
- reset();
- servletRequest.setMethod("GET");
- servletRequest.setRequestURI(locationFinal);
-
- tusFileUploadService.process(servletRequest, servletResponse);
- assertResponseStatus(204);
- assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
- assertThat(servletResponse.getContentAsString(), is(""));
-
- // Upload part 1 bytes
- reset();
- servletRequest.setMethod("PATCH");
- servletRequest.setRequestURI(location1);
- servletRequest.addHeader(HttpHeader.CONTENT_TYPE, "application/offset+octet-stream");
- servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, part1.getBytes().length);
- servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, part1.getBytes().length);
- servletRequest.addHeader(HttpHeader.UPLOAD_OFFSET, 0);
- servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
- servletRequest.setContent(part1.getBytes());
-
- tusFileUploadService.process(servletRequest, servletResponse);
- assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
- assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
- assertResponseHeaderNotBlank(HttpHeader.UPLOAD_EXPIRES);
- assertResponseHeader(HttpHeader.UPLOAD_OFFSET, "" + part1.getBytes().length);
- assertResponseStatus(HttpServletResponse.SC_NO_CONTENT);
-
- // Upload part 3 bytes
- reset();
- servletRequest.setMethod("PATCH");
- servletRequest.setRequestURI(location3);
- servletRequest.addHeader(HttpHeader.CONTENT_TYPE, "application/offset+octet-stream");
- servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, part3.getBytes().length);
- servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, part3.getBytes().length);
- servletRequest.addHeader(HttpHeader.UPLOAD_OFFSET, 0);
- servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
- servletRequest.setContent(part3.getBytes());
-
- tusFileUploadService.process(servletRequest, servletResponse);
- assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
- assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
- assertResponseHeaderNotBlank(HttpHeader.UPLOAD_EXPIRES);
- assertResponseHeader(HttpHeader.UPLOAD_OFFSET, "" + part3.getBytes().length);
- assertResponseStatus(HttpServletResponse.SC_NO_CONTENT);
-
- // Check with HEAD request length of final upload is known
- reset();
- servletRequest.setMethod("HEAD");
- servletRequest.setRequestURI(locationFinal);
- servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
-
- tusFileUploadService.process(servletRequest, servletResponse);
- assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
- assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
- assertResponseHeader(HttpHeader.UPLOAD_OFFSET, "" + (part1 + part2 + part3).getBytes().length);
- assertResponseHeader(HttpHeader.UPLOAD_LENGTH, "" + (part1 + part2 + part3).getBytes().length);
- assertResponseHeader(HttpHeader.UPLOAD_METADATA, "filename ZmluYWwucGRm");
- assertResponseHeader(
- HttpHeader.UPLOAD_CONCAT, "final;" + location1 + " " + location2 + " " + location3);
- assertResponseHeaderNull(HttpHeader.UPLOAD_DEFER_LENGTH);
- assertResponseStatus(HttpServletResponse.SC_NO_CONTENT);
-
- // Download the upload
- reset();
- servletRequest.setMethod("GET");
- servletRequest.setRequestURI(locationFinal);
-
- tusFileUploadService.process(servletRequest, servletResponse, null);
- assertResponseStatus(HttpServletResponse.SC_OK);
- assertResponseHeader(HttpHeader.CONTENT_LENGTH, "" + (part1 + part2 + part3).getBytes().length);
- assertResponseHeader(HttpHeader.UPLOAD_METADATA, "filename ZmluYWwucGRm");
- assertThat(
- servletResponse.getContentAsString(),
- is(
- "When sending this part, the final upload was already created. "
- + "This is the second part of our concatenated upload. "
- + "Finally when sending the third part, the final upload is complete."));
-
- // Get uploaded bytes from service
- try (InputStream uploadedBytes = tusFileUploadService.getUploadedBytes(locationFinal, null)) {
- assertThat(
- IOUtils.toString(uploadedBytes, StandardCharsets.UTF_8),
- is(
- "When sending this part, the final upload was already created. "
- + "This is the second part of our concatenated upload. "
- + "Finally when sending the third part, the final upload is complete."));
- }
- }
-
- @Test
- public void testChunkedDecodingDisabledAndRegexUploadUri() throws Exception {
- String chunkedContent =
- "1B;test=value\r\nThis upload looks chunked, \r\n" + "D\r\nbut it's not!\r\n" + "\r\n0\r\n";
-
- // Create service without chunked decoding
- tusFileUploadService =
- new TusFileUploadService()
- .withUploadUri("/users/[0-9]+/files/upload")
- .withStoragePath(storagePath.toAbsolutePath().toString())
- .withDownloadFeature();
-
- // Create upload
- servletRequest.setMethod("POST");
- servletRequest.setRequestURI("/users/98765/files/upload");
- servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, 0);
- servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, "67");
- servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
- servletRequest.addHeader(
- HttpHeader.UPLOAD_METADATA, "filename d29ybGRfZG9taW5hdGlvbl9wbGFuLnBkZg==");
-
- tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
- assertResponseHeaderNotBlank(HttpHeader.LOCATION);
- assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
- assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
- assertResponseHeaderNull(HttpHeader.UPLOAD_EXPIRES);
- assertResponseStatus(HttpServletResponse.SC_CREATED);
-
- String location = servletResponse.getHeader(HttpHeader.LOCATION);
-
- // Upload content
- reset();
- servletRequest.setMethod("PATCH");
- servletRequest.setRequestURI(location);
- servletRequest.addHeader(HttpHeader.CONTENT_TYPE, "application/offset+octet-stream");
- servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, "67");
- servletRequest.addHeader(HttpHeader.UPLOAD_OFFSET, 0);
- servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
- servletRequest.addHeader(HttpHeader.TRANSFER_ENCODING, "chunked");
- servletRequest.setContent(chunkedContent.getBytes());
-
- tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
- assertResponseStatus(HttpServletResponse.SC_NO_CONTENT);
- assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
- assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
- assertResponseHeaderNull(HttpHeader.UPLOAD_EXPIRES);
- assertResponseHeader(HttpHeader.UPLOAD_OFFSET, "67");
-
- // Check with HEAD request upload is complete
- reset();
- servletRequest.setMethod("HEAD");
- servletRequest.setRequestURI(location);
- servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
-
- tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
- assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
- assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
- assertResponseHeader(HttpHeader.UPLOAD_OFFSET, "67");
- assertResponseHeader(HttpHeader.UPLOAD_LENGTH, "67");
- assertResponseHeaderNull(HttpHeader.UPLOAD_DEFER_LENGTH);
- assertResponseHeader(
- HttpHeader.UPLOAD_METADATA, "filename d29ybGRfZG9taW5hdGlvbl9wbGFuLnBkZg==");
- assertResponseStatus(HttpServletResponse.SC_NO_CONTENT);
-
- // Get upload info from service
- UploadInfo info = tusFileUploadService.getUploadInfo(location, OWNER_KEY);
- assertFalse(info.isUploadInProgress());
- assertThat(info.getLength(), is(67L));
- assertThat(info.getOffset(), is(67L));
- assertThat(
- info.getMetadata(), allOf(hasSize(1), hasEntry("filename", "world_domination_plan.pdf")));
-
- // Get uploaded bytes from service
- try (InputStream uploadedBytes = tusFileUploadService.getUploadedBytes(location, OWNER_KEY)) {
- assertThat(
- IOUtils.toString(uploadedBytes, StandardCharsets.UTF_8),
- is(
- "1B;test=value\r\nThis upload looks chunked, \r\n"
- + "D\r\nbut it's not!\r\n"
- + "\r\n0\r\n"));
- }
- }
-
- @Test
- public void testOptions() throws Exception {
- // Do options request and check response headers
- servletRequest.setMethod("OPTIONS");
-
- tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
+ protected static Path storagePath;
- assertResponseStatus(HttpServletResponse.SC_NO_CONTENT);
- assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
- assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
- assertResponseHeader(HttpHeader.TUS_VERSION, "1.0.0");
- assertResponseHeader(HttpHeader.TUS_MAX_SIZE, "1073741824");
- assertResponseHeader(
- HttpHeader.TUS_CHECKSUM_ALGORITHM, "md5", "sha1", "sha256", "sha384", "sha512");
- assertResponseHeader(
- HttpHeader.TUS_EXTENSION,
- "creation",
- "creation-defer-length",
- "creation-with-upload",
- "checksum",
- "checksum-trailer",
- "termination",
- "download",
- "expiration",
- "concatenation",
- "concatenation-unfinished");
+ @BeforeClass
+ public static void setupDataFolder() throws IOException {
+ storagePath = Paths.get("target", "tus", "data").toAbsolutePath();
+ Files.createDirectories(storagePath);
}
- @Test
- public void testHeadOnNonExistingUpload() throws Exception {
- servletRequest.setMethod("HEAD");
- servletRequest.setRequestURI(UPLOAD_URI + "/" + UUID.randomUUID());
- servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
-
- tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
- assertResponseStatus(HttpServletResponse.SC_NOT_FOUND);
- assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
- assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
+ @AfterClass
+ public static void destroyDataFolder() throws IOException {
+ FileUtils.deleteDirectory(storagePath.toFile());
}
- @Test
- public void testInvalidTusResumable() throws Exception {
- servletRequest.setMethod("POST");
- servletRequest.setRequestURI(UPLOAD_URI);
- servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, 0);
- servletRequest.addHeader(HttpHeader.UPLOAD_DEFER_LENGTH, 1);
- servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "2.0.0");
-
- tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
- assertResponseStatus(HttpServletResponse.SC_PRECONDITION_FAILED);
- assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
- assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
+ @Override
+ protected TusFileUploadService createTusFileUploadService() {
+ return createTusFileUploadService(UPLOAD_URI);
}
- @Test
- public void testMaxUploadLengthExceeded() throws Exception {
- tusFileUploadService.withMaxUploadSize(10L);
-
- String uploadContent = "This is upload is too long";
-
- // Create upload
- servletRequest.setMethod("POST");
- servletRequest.setRequestURI(UPLOAD_URI);
- servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, 0);
- servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, uploadContent.getBytes().length);
- servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
-
- tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
- assertResponseStatus(HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE);
- assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
- assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
+ @Override
+ protected TusFileUploadService createTusFileUploadService(String uploadUri) {
+ return new TusFileUploadService()
+ .withUploadUri(uploadUri)
+ .withStoragePath(storagePath.toAbsolutePath().toString())
+ .withMaxUploadSize(1073741824L)
+ .withUploadExpirationPeriod(2L * 24 * 60 * 60 * 1000)
+ .withDownloadFeature()
+ .withChunkedTransferDecoding(true);
}
- @Test
- public void testInvalidMethods() throws Exception {
- servletRequest.setMethod("PUT");
- servletRequest.setRequestURI(UPLOAD_URI + "/" + UUID.randomUUID());
- servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
-
- tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
- assertResponseStatus(HttpServletResponse.SC_METHOD_NOT_ALLOWED);
-
- reset();
- servletRequest.setMethod("CONNECT");
- servletRequest.setRequestURI(UPLOAD_URI + "/" + UUID.randomUUID());
- servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
-
- tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
- assertResponseStatus(HttpServletResponse.SC_METHOD_NOT_ALLOWED);
- assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
- assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
-
- reset();
- servletRequest.setMethod("TRACE");
- servletRequest.setRequestURI(UPLOAD_URI + "/" + UUID.randomUUID());
- servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
-
- tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
- assertResponseStatus(HttpServletResponse.SC_METHOD_NOT_ALLOWED);
- assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
- assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0");
- }
+ // ===============================================================================================
+ // DISK-SPECIFIC STORAGE TESTS
+ // ===============================================================================================
+ /**
+ * Disk Storage Specific Test: Verify automatic file deduplication on upload completion,
+ * inspecting physical disk file structures, checking child data file non-existence, direct file
+ * manipulation on disk, and cleanup on parent deletion.
+ */
@Test
- public void testFileDeduplicationEndToEnd() throws Exception {
- // Enable deduplication feature
+ public void testAutomaticDeduplicationOnUploadCompletion() throws Exception {
+ // Step 1: Enable deduplication feature on disk storage
tusFileUploadService.withUploadDeduplication(true);
String uploadContent = "Deduplication integration test content";
- // 1. First upload (Parent)
+ // Step 2: Upload parent file
servletRequest.setMethod("POST");
servletRequest.setRequestURI(UPLOAD_URI);
servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, 0);
@@ -1511,7 +93,7 @@ public void testFileDeduplicationEndToEnd() throws Exception {
tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
assertResponseStatus(HttpServletResponse.SC_NO_CONTENT);
- // 2. Second upload (Child/Duplicate)
+ // Step 3: Upload identical duplicate child file
reset();
servletRequest.setMethod("POST");
servletRequest.setRequestURI(UPLOAD_URI);
@@ -1537,23 +119,23 @@ public void testFileDeduplicationEndToEnd() throws Exception {
tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
assertResponseStatus(HttpServletResponse.SC_NO_CONTENT);
- // 3. Verify deduplication succeeded
+ // Step 4: Verify deduplication metadata link (child duplicates parent)
UploadInfo parentInfo = tusFileUploadService.getUploadInfo(parentLocation, OWNER_KEY);
UploadInfo childInfo = tusFileUploadService.getUploadInfo(childLocation, OWNER_KEY);
assertThat(childInfo.getDuplicatesUploadId(), is(parentInfo.getId()));
- // Verify child upload physical data file does NOT exist
+ // Step 5: Verify child upload physical data file does NOT exist on disk
Path childDataPath =
storagePath.resolve("uploads").resolve(childInfo.getId().toString()).resolve("data");
assertFalse(Files.exists(childDataPath));
- // Verify parent upload physical data file DOES exist
+ // Step 6: Verify parent upload physical data file DOES exist on disk
Path parentDataPath =
storagePath.resolve("uploads").resolve(parentInfo.getId().toString()).resolve("data");
assertTrue(Files.exists(parentDataPath));
- // Verify child download retrieves parent's content
+ // Step 7: Verify downloading child retrieves parent's content
reset();
servletRequest.setMethod("GET");
servletRequest.setRequestURI(childLocation);
@@ -1561,12 +143,12 @@ public void testFileDeduplicationEndToEnd() throws Exception {
assertResponseStatus(HttpServletResponse.SC_OK);
assertThat(servletResponse.getContentAsString(), is(uploadContent));
- // Manipulate parent file directly on disk
+ // Step 8: Directly modify parent data file on physical disk
String manipulatedContent = "manipulated content on disk";
Files.write(
parentDataPath, manipulatedContent.getBytes(java.nio.charset.StandardCharsets.UTF_8));
- // Verify parent download retrieves the manipulated content
+ // Step 9: Verify parent and child downloads both return the manipulated content
reset();
servletRequest.setMethod("GET");
servletRequest.setRequestURI(parentLocation);
@@ -1574,7 +156,6 @@ public void testFileDeduplicationEndToEnd() throws Exception {
assertResponseStatus(HttpServletResponse.SC_OK);
assertThat(servletResponse.getContentAsString(), is(manipulatedContent));
- // Verify child download retrieves the same manipulated content
reset();
servletRequest.setMethod("GET");
servletRequest.setRequestURI(childLocation);
@@ -1582,433 +163,13 @@ public void testFileDeduplicationEndToEnd() throws Exception {
assertResponseStatus(HttpServletResponse.SC_OK);
assertThat(servletResponse.getContentAsString(), is(manipulatedContent));
- // 4. Delete parent upload
+ // Step 10: Delete parent upload and verify child download returns 404
tusFileUploadService.deleteUpload(parentLocation, OWNER_KEY);
- // 5. Attempt child download: should return 404 Not Found since parent is deleted
reset();
servletRequest.setMethod("GET");
servletRequest.setRequestURI(childLocation);
tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
assertResponseStatus(HttpServletResponse.SC_NOT_FOUND);
}
-
- @Test
- public void testLockContentionAndHeadRelease() throws Exception {
- // 1. Create upload resource
- servletRequest.setMethod("POST");
- servletRequest.setRequestURI(UPLOAD_URI);
- servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, 0);
- servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, 1000L);
- servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
- tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
- String location =
- UPLOAD_URI
- + StringUtils.substringAfter(
- servletResponse.getHeader(HttpHeader.LOCATION), UPLOAD_URI);
-
- // 2. Start a blocking PATCH in a background thread to hold the lock
- final java.util.concurrent.CountDownLatch requestStarted =
- new java.util.concurrent.CountDownLatch(1);
- final java.util.concurrent.atomic.AtomicReference bgException =
- new java.util.concurrent.atomic.AtomicReference<>();
-
- InputStream blockingStream =
- new InputStream() {
- private volatile boolean closed = false;
-
- @Override
- public int read() throws IOException {
- requestStarted.countDown();
- synchronized (this) {
- while (!closed) {
- try {
- this.wait(100);
- } catch (InterruptedException e) {
- Thread.currentThread().interrupt();
- throw new IOException("Interrupted", e);
- }
- }
- }
- throw new IOException("Stream closed");
- }
-
- @Override
- public void close() throws IOException {
- synchronized (this) {
- closed = true;
- this.notifyAll();
- }
- }
- };
-
- final MockHttpServletRequest bgRequest =
- new MockHttpServletRequest() {
- @Override
- public jakarta.servlet.ServletInputStream getInputStream() {
- return new jakarta.servlet.ServletInputStream() {
- @Override
- public int read() throws IOException {
- return blockingStream.read();
- }
-
- @Override
- public void close() throws IOException {
- blockingStream.close();
- }
-
- @Override
- public boolean isFinished() {
- return false;
- }
-
- @Override
- public boolean isReady() {
- return true;
- }
-
- @Override
- public void setReadListener(jakarta.servlet.ReadListener readListener) {}
- };
- }
- };
- bgRequest.setMethod("PATCH");
- bgRequest.setRequestURI(location);
- bgRequest.addHeader(HttpHeader.CONTENT_TYPE, "application/offset+octet-stream");
- bgRequest.addHeader(HttpHeader.CONTENT_LENGTH, 100);
- bgRequest.addHeader(HttpHeader.UPLOAD_OFFSET, 0);
- bgRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
-
- Thread bgThread =
- new Thread(
- new Runnable() {
- @Override
- public void run() {
- try {
- MockHttpServletResponse bgResponse = new MockHttpServletResponse();
- tusFileUploadService.process(bgRequest, bgResponse, OWNER_KEY);
- } catch (Exception e) {
- e.printStackTrace();
- bgException.set(e);
- }
- }
- });
- bgThread.start();
-
- // Wait for the background thread to start reading (meaning it holds the lock)
- requestStarted.await(2, java.util.concurrent.TimeUnit.SECONDS);
-
- // 3. Concurrent PATCH request must fail immediately with 423
- MockHttpServletRequest patchRequest = new MockHttpServletRequest();
- MockHttpServletResponse patchResponse = new MockHttpServletResponse();
- patchRequest.setMethod("PATCH");
- patchRequest.setRequestURI(location);
- patchRequest.addHeader(HttpHeader.CONTENT_TYPE, "application/offset+octet-stream");
- patchRequest.addHeader(HttpHeader.CONTENT_LENGTH, 10);
- patchRequest.addHeader(HttpHeader.UPLOAD_OFFSET, 0);
- patchRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
- patchRequest.setContent(new byte[10]);
-
- tusFileUploadService.process(patchRequest, patchResponse, OWNER_KEY);
- assertThat(patchResponse.getStatus(), is(423));
-
- // 4. Concurrent HEAD request must interrupt background thread and succeed
- MockHttpServletRequest headRequest = new MockHttpServletRequest();
- MockHttpServletResponse headResponse = new MockHttpServletResponse();
- headRequest.setMethod("HEAD");
- headRequest.setRequestURI(location);
- headRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
-
- tusFileUploadService.process(headRequest, headResponse, OWNER_KEY);
- assertThat(headResponse.getStatus(), is(204));
- assertThat(headResponse.getHeader(HttpHeader.UPLOAD_OFFSET), is("0"));
-
- // Clean up
- bgThread.join(2000);
- }
-
- @Test
- public void testCreationWithUploadOptions() throws Exception {
- reset();
- servletRequest.setMethod("OPTIONS");
- servletRequest.setRequestURI(UPLOAD_URI);
- tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
- assertResponseHeader(
- HttpHeader.TUS_EXTENSION,
- "creation",
- "creation-defer-length",
- "creation-with-upload",
- "checksum",
- "checksum-trailer",
- "termination",
- "download",
- "expiration",
- "concatenation",
- "concatenation-unfinished");
- }
-
- @Test
- public void testCreationWithUploadSuccess() throws Exception {
- String uploadContent = "Initial data to upload";
- byte[] contentBytes = uploadContent.getBytes(StandardCharsets.UTF_8);
-
- reset();
- servletRequest.setMethod("POST");
- servletRequest.setRequestURI(UPLOAD_URI);
- servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
- servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, contentBytes.length);
- servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, contentBytes.length);
- servletRequest.addHeader(HttpHeader.CONTENT_TYPE, "application/offset+octet-stream");
- servletRequest.setContent(contentBytes);
-
- tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
- assertResponseStatus(HttpServletResponse.SC_CREATED);
- assertResponseHeaderNotBlank(HttpHeader.LOCATION);
- assertResponseHeader(HttpHeader.UPLOAD_OFFSET, String.valueOf(contentBytes.length));
-
- String location = servletResponse.getHeader(HttpHeader.LOCATION);
-
- // Verify content in storage
- try (InputStream is = tusFileUploadService.getUploadedBytes(location, OWNER_KEY)) {
- String readContent = IOUtils.toString(is, StandardCharsets.UTF_8);
- assertThat(readContent, is(uploadContent));
- }
- }
-
- @Test
- public void testCreationWithUploadDeferredLengthSuccess() throws Exception {
- String uploadContent = "Initial data to upload with deferred length";
- byte[] contentBytes = uploadContent.getBytes(StandardCharsets.UTF_8);
-
- reset();
- servletRequest.setMethod("POST");
- servletRequest.setRequestURI(UPLOAD_URI);
- servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
- servletRequest.addHeader(HttpHeader.UPLOAD_DEFER_LENGTH, "1");
- servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, contentBytes.length);
- servletRequest.addHeader(HttpHeader.CONTENT_TYPE, "application/offset+octet-stream");
- servletRequest.setContent(contentBytes);
-
- tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
- assertResponseStatus(HttpServletResponse.SC_CREATED);
- assertResponseHeaderNotBlank(HttpHeader.LOCATION);
- assertResponseHeader(HttpHeader.UPLOAD_OFFSET, String.valueOf(contentBytes.length));
-
- String location = servletResponse.getHeader(HttpHeader.LOCATION);
-
- // Verify content in storage
- try (InputStream is = tusFileUploadService.getUploadedBytes(location, OWNER_KEY)) {
- String readContent = IOUtils.toString(is, StandardCharsets.UTF_8);
- assertThat(readContent, is(uploadContent));
- }
- }
-
- @Test
- public void testCreationWithUploadInvalidContentType() throws Exception {
- String uploadContent = "Initial data to upload";
- byte[] contentBytes = uploadContent.getBytes(StandardCharsets.UTF_8);
-
- reset();
- servletRequest.setMethod("POST");
- servletRequest.setRequestURI(UPLOAD_URI);
- servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
- servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, contentBytes.length);
- servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, contentBytes.length);
- servletRequest.addHeader(HttpHeader.CONTENT_TYPE, "application/octet-stream");
- servletRequest.setContent(contentBytes);
-
- tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
- assertResponseStatus(HttpServletResponse.SC_NOT_ACCEPTABLE);
- }
-
- @Test
- public void testCreationWithUploadExceedsLength() throws Exception {
- String uploadContent = "Initial data to upload";
- byte[] contentBytes = uploadContent.getBytes(StandardCharsets.UTF_8);
-
- reset();
- servletRequest.setMethod("POST");
- servletRequest.setRequestURI(UPLOAD_URI);
- servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
- servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, contentBytes.length - 5);
- servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, contentBytes.length);
- servletRequest.addHeader(HttpHeader.CONTENT_TYPE, "application/offset+octet-stream");
- servletRequest.setContent(contentBytes);
-
- tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
- assertResponseStatus(HttpServletResponse.SC_BAD_REQUEST);
- }
-
- @Test
- public void testCreationWithUploadDisabled() throws Exception {
- String uploadContent = "Initial data to upload";
- byte[] contentBytes = uploadContent.getBytes(StandardCharsets.UTF_8);
-
- tusFileUploadService.disableTusExtension("creation-with-upload");
- try {
- reset();
- servletRequest.setMethod("POST");
- servletRequest.setRequestURI(UPLOAD_URI);
- servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
- servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, contentBytes.length);
- servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, contentBytes.length);
- servletRequest.addHeader(HttpHeader.CONTENT_TYPE, "application/offset+octet-stream");
- servletRequest.setContent(contentBytes);
-
- tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
- assertResponseStatus(HttpServletResponse.SC_BAD_REQUEST);
- } finally {
- // Restore for other tests
- tusFileUploadService =
- new TusFileUploadService()
- .withUploadUri(UPLOAD_URI)
- .withStoragePath(storagePath.toAbsolutePath().toString())
- .withMaxUploadSize(1073741824L)
- .withUploadExpirationPeriod(2L * 24 * 60 * 60 * 1000)
- .withDownloadFeature()
- .withChunkedTransferDecoding(true);
- }
- }
-
- @Test
- public void testCorsHeaders() throws Exception {
- reset();
- servletRequest.setMethod("POST");
- servletRequest.setRequestURI(UPLOAD_URI);
- servletRequest.addHeader("Origin", "https://example.com");
- servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
- servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, 100L);
-
- tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
- assertResponseHeader("Access-Control-Allow-Origin", "https://example.com");
- assertResponseHeaderNotBlank("Access-Control-Expose-Headers");
- }
-
- @Test
- public void testCorsPreflight() throws Exception {
- reset();
- servletRequest.setMethod("OPTIONS");
- servletRequest.setRequestURI(UPLOAD_URI);
- servletRequest.addHeader("Origin", "https://example.com");
- servletRequest.addHeader("Access-Control-Request-Method", "PATCH");
-
- tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
- assertResponseHeader("Access-Control-Allow-Origin", "https://example.com");
- assertResponseHeader("Access-Control-Allow-Methods", "POST, GET, HEAD, PATCH, DELETE, OPTIONS");
- assertResponseHeaderNotBlank("Access-Control-Allow-Headers");
- assertResponseHeader("Access-Control-Max-Age", "86400");
- }
-
- @Test
- public void testTusVersionHeaderOn412() throws Exception {
- reset();
- servletRequest.setMethod("POST");
- servletRequest.setRequestURI(UPLOAD_URI);
- servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "2.0.0");
-
- tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
- assertResponseStatus(HttpServletResponse.SC_PRECONDITION_FAILED);
- assertResponseHeader(HttpHeader.TUS_VERSION, "1.0.0");
- }
-
- @Test
- public void testModifyUploadLengthOnPatch() throws Exception {
- // 1. Create upload with deferred length
- reset();
- servletRequest.setMethod("POST");
- servletRequest.setRequestURI(UPLOAD_URI);
- servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
- servletRequest.addHeader(HttpHeader.UPLOAD_DEFER_LENGTH, "1");
- tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
- assertResponseStatus(HttpServletResponse.SC_CREATED);
- String location = servletResponse.getHeader(HttpHeader.LOCATION);
-
- // 2. Set length to 100 on PATCH
- reset();
- servletRequest.setMethod("PATCH");
- servletRequest.setRequestURI(location);
- servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
- servletRequest.addHeader(HttpHeader.UPLOAD_OFFSET, "0");
- servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, "100");
- servletRequest.addHeader(HttpHeader.CONTENT_TYPE, "application/offset+octet-stream");
- servletRequest.setContent("test content".getBytes(StandardCharsets.UTF_8));
- tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
- assertResponseStatus(HttpServletResponse.SC_NO_CONTENT);
-
- // 3. Try to change length to 200 on subsequent PATCH -> should return 400
- reset();
- servletRequest.setMethod("PATCH");
- servletRequest.setRequestURI(location);
- servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
- servletRequest.addHeader(HttpHeader.UPLOAD_OFFSET, "12");
- servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, "200");
- servletRequest.addHeader(HttpHeader.CONTENT_TYPE, "application/offset+octet-stream");
- servletRequest.setContent("more content".getBytes(StandardCharsets.UTF_8));
- tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
- assertResponseStatus(HttpServletResponse.SC_BAD_REQUEST);
- }
-
- @Test
- public void testCreationWithUploadChecksumSuccess() throws Exception {
- String uploadContent = "Initial data to upload with checksum";
- byte[] contentBytes = uploadContent.getBytes(StandardCharsets.UTF_8);
- // Base64 hash for MD5 of "Initial data to upload with checksum"
- // Base64 of MD5 bytes: aAMsB0BZbWXCBuPWG/ADyA==
- String base64Checksum = "aAMsB0BZbWXCBuPWG/ADyA==";
-
- reset();
- servletRequest.setMethod("POST");
- servletRequest.setRequestURI(UPLOAD_URI);
- servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
- servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, contentBytes.length);
- servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, contentBytes.length);
- servletRequest.addHeader(HttpHeader.CONTENT_TYPE, "application/offset+octet-stream");
- servletRequest.addHeader(HttpHeader.UPLOAD_CHECKSUM, "md5 " + base64Checksum);
- servletRequest.setContent(contentBytes);
-
- tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
- assertResponseStatus(HttpServletResponse.SC_CREATED);
- assertResponseHeader(HttpHeader.UPLOAD_OFFSET, String.valueOf(contentBytes.length));
- }
-
- @Test
- public void testCreationWithUploadChecksumMismatch() throws Exception {
- String uploadContent = "Initial data to upload with checksum";
- byte[] contentBytes = uploadContent.getBytes(StandardCharsets.UTF_8);
- String invalidBase64Checksum = "aAMsB0BZbWXCBuPWG/ADyB=="; // changed A to B
-
- reset();
- servletRequest.setMethod("POST");
- servletRequest.setRequestURI(UPLOAD_URI);
- servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0");
- servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, contentBytes.length);
- servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, contentBytes.length);
- servletRequest.addHeader(HttpHeader.CONTENT_TYPE, "application/offset+octet-stream");
- servletRequest.addHeader(HttpHeader.UPLOAD_CHECKSUM, "md5 " + invalidBase64Checksum);
- servletRequest.setContent(contentBytes);
-
- tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY);
- assertResponseStatus(460); // Checksum mismatch
- }
-
- protected void assertResponseHeader(final String header, final String value) {
- assertThat(servletResponse.getHeader(header), is(value));
- }
-
- protected void assertResponseHeader(final String header, final String... values) {
- assertThat(
- Arrays.asList(servletResponse.getHeader(header).split(",")), containsInAnyOrder(values));
- }
-
- protected void assertResponseHeaderNotBlank(final String header) {
- assertTrue(StringUtils.isNotBlank(servletResponse.getHeader(header)));
- }
-
- protected void assertResponseHeaderNull(final String header) {
- assertNull(servletResponse.getHeader(header));
- }
-
- protected void assertResponseStatus(final int httpStatus) {
- assertThat(servletResponse.getStatus(), is(httpStatus));
- }
}
diff --git a/src/test/java/me/desair/tus/server/ITTusFileUploadServiceCached.java b/src/test/java/me/desair/tus/server/ITTusFileUploadServiceCached.java
index 4d619555..2cb9f630 100644
--- a/src/test/java/me/desair/tus/server/ITTusFileUploadServiceCached.java
+++ b/src/test/java/me/desair/tus/server/ITTusFileUploadServiceCached.java
@@ -19,7 +19,7 @@ public class ITTusFileUploadServiceCached extends ITTusFileUploadService {
@Override
@Before
- public void setUp() {
+ public void setUp() throws Exception {
super.setUp();
tusFileUploadService =
tusFileUploadService
diff --git a/src/test/java/me/desair/tus/server/TestUtils.java b/src/test/java/me/desair/tus/server/TestUtils.java
new file mode 100644
index 00000000..d7659b64
--- /dev/null
+++ b/src/test/java/me/desair/tus/server/TestUtils.java
@@ -0,0 +1,107 @@
+package me.desair.tus.server;
+
+import io.minio.BucketExistsArgs;
+import io.minio.MakeBucketArgs;
+import io.minio.MinioClient;
+import org.testcontainers.DockerClientFactory;
+import org.testcontainers.containers.GenericContainer;
+
+/**
+ * Helper utility class for S3 integration tests running against Testcontainers MinIO using the
+ * MinIO Java SDK. Supports both Docker and Podman container engines automatically.
+ */
+public final class TestUtils {
+
+ private TestUtils() {
+ // Utility class
+ }
+
+ /**
+ * Check if a container runtime (Docker or Podman) is available locally.
+ *
+ * @return True if Docker or Podman is available for Testcontainers, false otherwise
+ */
+ public static boolean isContainerRuntimeAvailable() {
+ try {
+ if (DockerClientFactory.instance().isDockerAvailable()) {
+ return true;
+ }
+ } catch (Throwable ignored) {
+ }
+
+ try {
+ String userHome = System.getProperty("user.home", "");
+ String[] possibleSockets = {
+ "/var/run/docker.sock",
+ "/run/podman/podman.sock",
+ userHome + "/.local/share/containers/podman/machine/podman-machine-default/podman.sock",
+ userHome + "/.local/share/containers/podman/machine/qemu/podman.sock"
+ };
+
+ for (String socketPath : possibleSockets) {
+ if (new java.io.File(socketPath).exists()) {
+ if (System.getProperty("DOCKER_HOST") == null) {
+ System.setProperty("DOCKER_HOST", "unix://" + socketPath);
+ }
+ System.setProperty("TESTCONTAINERS_RYUK_DISABLED", "true");
+ try {
+ if (DockerClientFactory.instance().isDockerAvailable()) {
+ return true;
+ }
+ } catch (Throwable ignored) {
+ }
+ }
+ }
+
+ Process process = new ProcessBuilder("podman", "info").start();
+ if (process.waitFor() == 0) {
+ System.setProperty("TESTCONTAINERS_RYUK_DISABLED", "true");
+ return DockerClientFactory.instance().isDockerAvailable();
+ }
+ } catch (Throwable ignored) {
+ }
+
+ return false;
+ }
+
+ /**
+ * Create and configure a GenericContainer running MinIO for integration testing.
+ *
+ * @return A configured GenericContainer instance (not started yet)
+ */
+ public static GenericContainer> createMinioContainer() {
+ return new GenericContainer<>("minio/minio:RELEASE.2024-01-16T16-07-38Z")
+ .withExposedPorts(9000)
+ .withEnv("MINIO_ROOT_USER", "minioadmin")
+ .withEnv("MINIO_ROOT_PASSWORD", "minioadmin")
+ .withCommand("server /data");
+ }
+
+ /**
+ * Create a {@link MinioClient} configured to connect to the given MinIO container.
+ *
+ * @param minio The active MinIO Testcontainer
+ * @return Pre-configured MinioClient
+ */
+ public static MinioClient createMinioClient(GenericContainer> minio) {
+ String minioUrl = "http://" + minio.getHost() + ":" + minio.getMappedPort(9000);
+ return MinioClient.builder().endpoint(minioUrl).credentials("minioadmin", "minioadmin").build();
+ }
+
+ /**
+ * Ensures an S3 bucket exists using MinIO Client.
+ *
+ * @param minioClient The MinIO Client
+ * @param bucket The S3 bucket name
+ */
+ public static void createBucket(MinioClient minioClient, String bucket) {
+ try {
+ boolean found = minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucket).build());
+ if (!found) {
+ minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucket).build());
+ }
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to create bucket " + bucket, e);
+ }
+ }
+}
diff --git a/src/test/java/me/desair/tus/server/TusFileUploadServiceTest.java b/src/test/java/me/desair/tus/server/TusFileUploadServiceTest.java
index 6d7257ae..55a2eee3 100644
--- a/src/test/java/me/desair/tus/server/TusFileUploadServiceTest.java
+++ b/src/test/java/me/desair/tus/server/TusFileUploadServiceTest.java
@@ -513,4 +513,13 @@ public void testGetRawInterimResponse() throws Exception {
org.junit.Assert.assertNull(
service.getRawInterimResponse((jakarta.servlet.http.HttpServletRequest) null, "owner-123"));
}
+
+ @Test
+ public void testWithJsonSerialization() {
+ TusFileUploadService service = new TusFileUploadService().withJsonSerialization();
+ org.junit.Assert.assertTrue(service.getUploadStorageService().isJsonSerializationEnabled());
+
+ service.withJsonSerialization(false);
+ org.junit.Assert.assertFalse(service.getUploadStorageService().isJsonSerializationEnabled());
+ }
}
diff --git a/src/test/java/me/desair/tus/server/upload/concatenation/VirtualConcatenationServiceTest.java b/src/test/java/me/desair/tus/server/upload/concatenation/VirtualConcatenationServiceTest.java
index d75b78c5..769a092c 100644
--- a/src/test/java/me/desair/tus/server/upload/concatenation/VirtualConcatenationServiceTest.java
+++ b/src/test/java/me/desair/tus/server/upload/concatenation/VirtualConcatenationServiceTest.java
@@ -341,4 +341,43 @@ public void getConcatenatedBytesNotFound() throws Exception {
concatenationService.getConcatenatedBytes(infoParent);
}
+
+ @Test(expected = UploadNotFoundException.class)
+ public void getPartialUploadsNotFound() throws Exception {
+ UploadInfo child1 = new UploadInfo();
+ child1.setId(new UploadId(UUID.randomUUID()));
+
+ UploadInfo infoParent = new UploadInfo();
+ infoParent.setId(new UploadId(UUID.randomUUID()));
+ infoParent.setConcatenationPartIds(
+ java.util.Collections.singletonList(child1.getId().toString()));
+
+ when(uploadStorageService.getUploadInfo(child1.getId().toString(), infoParent.getOwnerKey()))
+ .thenReturn(null);
+
+ concatenationService.getPartialUploads(infoParent);
+ }
+
+ @Test
+ public void testMergeHandlesUploadNotFoundExceptionOnUpdate() throws Exception {
+ UploadInfo child1 = new UploadInfo();
+ child1.setId(new UploadId(UUID.randomUUID()));
+ child1.setLength(5L);
+ child1.setOffset(5L);
+
+ UploadInfo infoParent = new UploadInfo();
+ infoParent.setId(new UploadId(UUID.randomUUID()));
+ infoParent.setConcatenationPartIds(
+ java.util.Collections.singletonList(child1.getId().toString()));
+
+ when(uploadStorageService.getUploadInfo(child1.getId().toString(), infoParent.getOwnerKey()))
+ .thenReturn(child1);
+
+ org.mockito.Mockito.doThrow(new UploadNotFoundException("Parent upload missing"))
+ .when(uploadStorageService)
+ .update(infoParent);
+
+ concatenationService.merge(infoParent);
+ assertThat(infoParent.getLength(), is(5L));
+ }
}
diff --git a/src/test/java/me/desair/tus/server/upload/disk/DiskStorageServiceTest.java b/src/test/java/me/desair/tus/server/upload/disk/DiskStorageServiceTest.java
index 533253ca..9a0666d8 100644
--- a/src/test/java/me/desair/tus/server/upload/disk/DiskStorageServiceTest.java
+++ b/src/test/java/me/desair/tus/server/upload/disk/DiskStorageServiceTest.java
@@ -972,5 +972,121 @@ public void setMaxAppendSize(Long maxAppendSize) {}
assertThat(anonymousService.getMinSize(), is(nullValue()));
anonymousService.setMinAppendSize(100L);
anonymousService.setMinSize(100L);
+ assertThat(anonymousService.isJsonSerializationEnabled(), is(false));
+ anonymousService.setJsonSerializationEnabled(true);
+ }
+
+ @Test
+ public void testJsonSerialization() throws Exception {
+ storageService.setJsonSerializationEnabled(true);
+ assertThat(storageService.isJsonSerializationEnabled(), is(true));
+
+ UploadInfo info = new UploadInfo();
+ info.setLength(1024L);
+ info.setEncodedMetadata("filename d29ybGQudHh0");
+
+ info = storageService.create(info, "owner-json");
+ UploadInfo retrieved = storageService.getUploadInfo(info.getId());
+
+ assertThat(retrieved, is(notNullValue()));
+ assertThat(retrieved.getLength(), is(1024L));
+ assertThat(retrieved.getOwnerKey(), is("owner-json"));
+ assertThat(retrieved.getFileName(), is("world.txt"));
+
+ Path infoPath = getUploadInfoPath(info.getId());
+ String fileContent = new String(Files.readAllBytes(infoPath), StandardCharsets.UTF_8);
+ assertThat(fileContent.contains("\"ownerKey\":\"owner-json\""), is(true));
+ }
+
+ @Test
+ public void testJsonSerializationFallbackAndInvalidFile() throws Exception {
+ storageService.setJsonSerializationEnabled(false);
+ UploadInfo info = new UploadInfo();
+ info.setLength(2048L);
+ info = storageService.create(info, "owner-legacy");
+
+ storageService.setJsonSerializationEnabled(true);
+ UploadInfo fallbackRetrieved = storageService.getUploadInfo(info.getId());
+ assertThat(fallbackRetrieved, is(notNullValue()));
+ assertThat(fallbackRetrieved.getLength(), is(2048L));
+
+ Path infoPath = getUploadInfoPath(info.getId());
+ Files.write(infoPath, "corrupted-data".getBytes(StandardCharsets.UTF_8));
+ UploadInfo corruptedRetrieved = storageService.getUploadInfo(info.getId());
+ assertThat(corruptedRetrieved, is(nullValue()));
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void testGetUploadInfoWithUnsafePathTraversalUploadId() throws Exception {
+ storageService.getUploadInfo(new UploadId(".."));
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void testGetUploadedBytesWithUnsafePathTraversalUploadId() throws Exception {
+ storageService.getUploadedBytes(new UploadId(".."));
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void testTerminateUploadWithUnsafePathTraversalUploadId() throws Exception {
+ UploadInfo info = new UploadInfo();
+ info.setId(new UploadId(".."));
+ storageService.terminateUpload(info);
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void testAppendWithUnsafePathTraversalUploadId() throws Exception {
+ UploadInfo info = new UploadInfo();
+ info.setId(new UploadId(".."));
+ storageService.append(info, new java.io.ByteArrayInputStream(new byte[10]));
+ }
+
+ @Test(expected = UploadNotFoundException.class)
+ public void testGetUploadedBytesMissingDataFileThrowsUploadNotFoundException() throws Exception {
+ UploadInfo info = new UploadInfo();
+ info.setLength(100L);
+ info = storageService.create(info, null);
+
+ info.setOffset(100L);
+ storageService.update(info);
+
+ Path bytesPath =
+ storagePath.resolve("uploads").resolve(info.getId().toString()).resolve("data");
+ Files.deleteIfExists(bytesPath);
+
+ storageService.getUploadedBytes(info.getId());
+ }
+
+ @Test
+ public void testCopyUploadToWithNullOrInProgressOrMissingDataFile() throws Exception {
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
+
+ // Case 1: In-progress upload
+ UploadInfo inProgress = new UploadInfo();
+ inProgress.setLength(100L);
+ inProgress.setOffset(50L);
+ inProgress = storageService.create(inProgress, null);
+ storageService.copyUploadTo(inProgress, baos);
+
+ // Case 2: Missing data file for completed upload
+ UploadInfo missingData = new UploadInfo();
+ missingData.setLength(10L);
+ missingData.setOffset(10L);
+ missingData = storageService.create(missingData, null);
+ Path dataPath = storagePath.resolve(missingData.getId().toString()).resolve("data");
+ Files.deleteIfExists(dataPath);
+
+ try {
+ storageService.copyUploadTo(missingData, baos);
+ } catch (UploadNotFoundException expected) {
+ }
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void testUnsafeChildDataFileDeletionThrowsIllegalArgumentException() throws Exception {
+ UploadInfo child = new UploadInfo();
+ child.setId(new UploadId(".."));
+ child.setDuplicatesUploadId(new UploadId("parent-123"));
+
+ storageService.update(child);
}
}
diff --git a/src/test/java/me/desair/tus/server/upload/disk/FileBasedLockTest.java b/src/test/java/me/desair/tus/server/upload/disk/FileBasedLockTest.java
index 0d39344a..345f1c3c 100644
--- a/src/test/java/me/desair/tus/server/upload/disk/FileBasedLockTest.java
+++ b/src/test/java/me/desair/tus/server/upload/disk/FileBasedLockTest.java
@@ -91,6 +91,41 @@ public void testLockIoException() throws UploadAlreadyLockedException, IOExcepti
lock.close();
}
+ @Test(expected = UploadAlreadyLockedException.class)
+ public void testOverlappingLockFileChannelCloseException() throws Exception {
+ UUID test = UUID.randomUUID();
+ Path path = storagePath.resolve(test.toString());
+ FileBasedLock lock =
+ new FileBasedLock("/test/upload/" + test.toString(), path) {
+ @Override
+ protected FileChannel createFileChannel() throws IOException {
+ FileChannel mockChannel = createFileChannelMock();
+ doReturn(null).when(mockChannel).tryLock(anyLong(), anyLong(), anyBoolean());
+ org.mockito.Mockito.doThrow(new IOException("Close error")).when(mockChannel).close();
+ return mockChannel;
+ }
+ };
+ }
+
+ @Test
+ public void testReleaseIOException() throws Exception {
+ UUID test = UUID.randomUUID();
+ Path path = storagePath.resolve(test.toString());
+ FileBasedLock lock =
+ new FileBasedLock("/test/upload/" + test.toString(), path) {
+ @Override
+ protected FileChannel createFileChannel() throws IOException {
+ FileChannel mockChannel = createFileChannelMock();
+ doReturn(org.mockito.Mockito.mock(java.nio.channels.FileLock.class))
+ .when(mockChannel)
+ .tryLock(anyLong(), anyLong(), anyBoolean());
+ org.mockito.Mockito.doThrow(new IOException("Close error")).when(mockChannel).close();
+ return mockChannel;
+ }
+ };
+ lock.release();
+ }
+
private FileChannel createFileChannelMock() throws IOException {
return spy(FileChannel.class);
}
diff --git a/src/test/java/me/desair/tus/server/upload/s3/ITS3LockingService.java b/src/test/java/me/desair/tus/server/upload/s3/ITS3LockingService.java
new file mode 100644
index 00000000..58248985
--- /dev/null
+++ b/src/test/java/me/desair/tus/server/upload/s3/ITS3LockingService.java
@@ -0,0 +1,82 @@
+package me.desair.tus.server.upload.s3;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+import io.minio.MinioClient;
+import me.desair.tus.server.TestUtils;
+import me.desair.tus.server.exception.UploadAlreadyLockedException;
+import me.desair.tus.server.upload.UploadId;
+import me.desair.tus.server.upload.UploadLock;
+import org.junit.AfterClass;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.testcontainers.containers.GenericContainer;
+
+public class ITS3LockingService {
+
+ private static GenericContainer> minio;
+ private static MinioClient minioClient;
+ private static final String BUCKET = "test-locking-service-bucket";
+
+ private S3LockingService lockingService;
+
+ @BeforeClass
+ public static void setUpClass() {
+ org.junit.Assume.assumeTrue(
+ "Container runtime is not available; skipping Testcontainers MinIO test",
+ TestUtils.isContainerRuntimeAvailable());
+
+ minio = TestUtils.createMinioContainer();
+ minio.start();
+
+ minioClient = TestUtils.createMinioClient(minio);
+ TestUtils.createBucket(minioClient, BUCKET);
+ }
+
+ @AfterClass
+ public static void tearDownClass() {
+ if (minio != null) {
+ minio.stop();
+ }
+ }
+
+ @Before
+ public void setUp() {
+ org.junit.Assume.assumeTrue(TestUtils.isContainerRuntimeAvailable());
+ lockingService = new S3LockingService(minioClient, BUCKET);
+ }
+
+ @Test
+ public void testLockAcquireAndRelease() throws Exception {
+ String uri = "/test/upload/24249a5b-01a4-4bf8-b67a-364273bb5a21";
+ UploadId id = new UploadId("24249a5b-01a4-4bf8-b67a-364273bb5a21");
+ UploadLock lock = lockingService.lockUploadByUri(uri);
+ assertNotNull(lock);
+
+ // Verify upload is locked
+ assertTrue(lockingService.isLocked(id));
+
+ // Release lock
+ lock.release();
+
+ // Verify lock is released
+ assertFalse(lockingService.isLocked(id));
+ }
+
+ @Test(expected = UploadAlreadyLockedException.class)
+ public void testConcurrentLockFails() throws Exception {
+ String uri = "/test/upload/24249a5b-01a4-4bf8-b67a-364273bb5a22";
+ UploadLock lock1 = lockingService.lockUploadByUri(uri);
+ assertNotNull(lock1);
+
+ try {
+ // Second lock attempt on same URI should throw UploadAlreadyLockedException
+ lockingService.lockUploadByUri(uri);
+ } finally {
+ lock1.release();
+ }
+ }
+}
diff --git a/src/test/java/me/desair/tus/server/upload/s3/ITS3RufhProtocol.java b/src/test/java/me/desair/tus/server/upload/s3/ITS3RufhProtocol.java
new file mode 100644
index 00000000..a064290b
--- /dev/null
+++ b/src/test/java/me/desair/tus/server/upload/s3/ITS3RufhProtocol.java
@@ -0,0 +1,58 @@
+package me.desair.tus.server.upload.s3;
+
+import io.minio.MinioClient;
+import me.desair.tus.server.AbstractITRufhProtocol;
+import me.desair.tus.server.TestUtils;
+import me.desair.tus.server.TusFileUploadService;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+
+/**
+ * End-to-end integration test suite verifying the IETF Resumable Uploads for HTTP (RUFH) protocol
+ * implementation backed by {@link S3StorageService} and {@link S3LockingService} on MinIO using the
+ * MinIO Java SDK.
+ */
+public class ITS3RufhProtocol extends AbstractITRufhProtocol {
+
+ private static org.testcontainers.containers.GenericContainer> minio;
+ private static MinioClient minioClient;
+ private static final String BUCKET = "test-rufh-s3-bucket";
+
+ @BeforeClass
+ public static void setUpClass() {
+ org.junit.Assume.assumeTrue(
+ "Container runtime is not available; skipping Testcontainers MinIO test",
+ TestUtils.isContainerRuntimeAvailable());
+
+ minio = TestUtils.createMinioContainer();
+ minio.start();
+
+ minioClient = TestUtils.createMinioClient(minio);
+ TestUtils.createBucket(minioClient, BUCKET);
+ }
+
+ @AfterClass
+ public static void tearDownClass() {
+ if (minio != null) {
+ minio.stop();
+ }
+ }
+
+ @Override
+ protected TusFileUploadService createTusFileUploadService() {
+ org.junit.Assume.assumeTrue(TestUtils.isContainerRuntimeAvailable());
+
+ S3StorageService s3Storage = new S3StorageService(minioClient, BUCKET);
+ S3LockingService s3Locking = new S3LockingService(minioClient, BUCKET);
+ S3ConcatenationService s3Concat = new S3ConcatenationService(minioClient, BUCKET, s3Storage);
+ s3Storage.setUploadConcatenationService(s3Concat);
+
+ return new TusFileUploadService()
+ .withUploadUri(UPLOAD_URI)
+ .withUploadStorageService(s3Storage)
+ .withUploadLockingService(s3Locking)
+ .withMaxUploadSize(1073741824L)
+ .withUploadExpirationPeriod(2L * 24 * 60 * 60 * 1000)
+ .withDownloadFeature();
+ }
+}
diff --git a/src/test/java/me/desair/tus/server/upload/s3/ITS3StorageService.java b/src/test/java/me/desair/tus/server/upload/s3/ITS3StorageService.java
new file mode 100644
index 00000000..544147a0
--- /dev/null
+++ b/src/test/java/me/desair/tus/server/upload/s3/ITS3StorageService.java
@@ -0,0 +1,181 @@
+package me.desair.tus.server.upload.s3;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+
+import io.minio.MinioClient;
+import java.io.ByteArrayInputStream;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import me.desair.tus.server.TestUtils;
+import me.desair.tus.server.checksum.ChecksumAlgorithm;
+import me.desair.tus.server.upload.UploadInfo;
+import org.apache.commons.codec.digest.DigestUtils;
+import org.apache.commons.io.IOUtils;
+import org.junit.AfterClass;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.testcontainers.containers.GenericContainer;
+
+public class ITS3StorageService {
+
+ private static GenericContainer> minio;
+ private static MinioClient minioClient;
+ private static final String BUCKET = "test-storage-service-bucket";
+
+ private S3StorageService storageService;
+
+ @BeforeClass
+ public static void setUpClass() {
+ org.junit.Assume.assumeTrue(
+ "Container runtime is not available; skipping Testcontainers MinIO test",
+ TestUtils.isContainerRuntimeAvailable());
+
+ minio = TestUtils.createMinioContainer();
+ minio.start();
+
+ minioClient = TestUtils.createMinioClient(minio);
+ TestUtils.createBucket(minioClient, BUCKET);
+ }
+
+ @AfterClass
+ public static void tearDownClass() {
+ if (minio != null) {
+ minio.stop();
+ }
+ }
+
+ @Before
+ public void setUp() {
+ org.junit.Assume.assumeTrue(TestUtils.isContainerRuntimeAvailable());
+ storageService = new S3StorageService(minioClient, BUCKET);
+ }
+
+ @Test
+ public void testFullUploadLifecycle() throws Exception {
+ UploadInfo info = new UploadInfo();
+ info.setLength(11L);
+
+ info = storageService.create(info, "owner1");
+ assertNotNull(info.getId());
+ assertNotNull(info.getStorageUploadId());
+ assertEquals("owner1", info.getOwnerKey());
+
+ // Append data
+ ByteArrayInputStream bais =
+ new ByteArrayInputStream("hello world".getBytes(StandardCharsets.UTF_8));
+ info = storageService.append(info, bais);
+ assertEquals(Long.valueOf(11), info.getOffset());
+
+ // Verify uploaded bytes
+ try (InputStream is = storageService.getUploadedBytes(info.getId())) {
+ assertNotNull(is);
+ assertEquals("hello world", IOUtils.toString(is, StandardCharsets.UTF_8));
+ }
+
+ // Verify getUploadInfo by URI and ID
+ UploadInfo fetched = storageService.getUploadInfo(info.getId());
+ assertNotNull(fetched);
+ assertEquals(Long.valueOf(11), fetched.getOffset());
+
+ // Terminate upload
+ storageService.terminateUpload(info);
+ assertNull(storageService.getUploadInfo(info.getId()));
+ }
+
+ @Test
+ public void testDeduplicationOnS3() throws Exception {
+ storageService.setUploadDeduplicationEnabled(true);
+
+ byte[] content = "S3 Deduplicated Content".getBytes(StandardCharsets.UTF_8);
+ String sha1Base64 =
+ org.apache.commons.codec.binary.Base64.encodeBase64String(DigestUtils.sha1(content));
+
+ // Parent upload
+ UploadInfo parent = new UploadInfo();
+ parent.setLength((long) content.length);
+ parent.setChecksum(sha1Base64);
+ parent.setChecksumAlgorithm(ChecksumAlgorithm.SHA1);
+ parent = storageService.create(parent, "owner1");
+
+ storageService.append(parent, new ByteArrayInputStream(content));
+
+ // Look up by checksum
+ UploadInfo found = storageService.getUploadInfoByChecksum(sha1Base64, ChecksumAlgorithm.SHA1);
+ assertNotNull(found);
+ assertEquals(parent.getId(), found.getId());
+ }
+
+ @Test
+ public void testMultipartChunkedUploadAndFinalizationOnS3() throws Exception {
+ UploadInfo info = new UploadInfo();
+ info.setLength(20L);
+ info = storageService.create(info, "owner-multipart");
+
+ // Part 1: append 10 bytes (creates an incomplete .part file)
+ byte[] part1 = "1234567890".getBytes(StandardCharsets.UTF_8);
+ info = storageService.append(info, new ByteArrayInputStream(part1));
+ assertEquals(Long.valueOf(10L), info.getOffset());
+
+ // Part 2: append 10 bytes (completes the upload, triggers leftover .part finalization on real
+ // S3)
+ byte[] part2 = "abcdefghij".getBytes(StandardCharsets.UTF_8);
+ info = storageService.append(info, new ByteArrayInputStream(part2));
+ assertEquals(Long.valueOf(20L), info.getOffset());
+
+ // Verify uploaded bytes on real S3
+ try (InputStream is = storageService.getUploadedBytes(info.getId())) {
+ assertNotNull(is);
+ assertEquals("1234567890abcdefghij", IOUtils.toString(is, StandardCharsets.UTF_8));
+ }
+ }
+
+ @Test
+ public void testTruncationOnRealS3() throws Exception {
+ // Scenario A: Truncate in-progress upload with .part object
+ UploadInfo info1 = new UploadInfo();
+ info1.setLength(50L);
+ info1 = storageService.create(info1, "owner-trunc1");
+
+ byte[] bytes1 =
+ "Hello, World! This is an in-progress payload.".getBytes(StandardCharsets.UTF_8);
+ info1 = storageService.append(info1, new ByteArrayInputStream(bytes1));
+ assertEquals(Long.valueOf(bytes1.length), info1.getOffset());
+
+ storageService.removeLastNumberOfBytes(info1, 10L);
+ assertEquals(Long.valueOf(bytes1.length - 10), info1.getOffset());
+
+ // Scenario B: Truncate completed upload
+ UploadInfo info2 = new UploadInfo();
+ info2.setLength(12L);
+ info2 = storageService.create(info2, "owner-trunc2");
+ info2 =
+ storageService.append(
+ info2, new ByteArrayInputStream("Hello World!".getBytes(StandardCharsets.UTF_8)));
+ assertEquals(Long.valueOf(12L), info2.getOffset());
+
+ storageService.removeLastNumberOfBytes(info2, 6L);
+ assertEquals(Long.valueOf(6L), info2.getOffset());
+ }
+
+ @Test
+ public void testCleanupExpiredUploadsOnS3() throws Exception {
+ UploadInfo info = new UploadInfo();
+ info.setLength(100L);
+ info = storageService.create(info, "owner-exp");
+
+ // Set expiration in the past
+ info.setExpirationTimestamp(System.currentTimeMillis() - 60000L);
+ storageService.update(info);
+
+ assertNotNull(storageService.getUploadInfo(info.getId()));
+
+ // Run cleanup on real S3
+ storageService.cleanupExpiredUploads(null);
+
+ // Verify upload was cleaned up from real S3
+ assertNull(storageService.getUploadInfo(info.getId()));
+ }
+}
diff --git a/src/test/java/me/desair/tus/server/upload/s3/ITS3TusFileUploadService.java b/src/test/java/me/desair/tus/server/upload/s3/ITS3TusFileUploadService.java
new file mode 100644
index 00000000..af3c0436
--- /dev/null
+++ b/src/test/java/me/desair/tus/server/upload/s3/ITS3TusFileUploadService.java
@@ -0,0 +1,67 @@
+package me.desair.tus.server.upload.s3;
+
+import io.minio.MinioClient;
+import me.desair.tus.server.AbstractITTusFileUploadService;
+import me.desair.tus.server.ProtocolVersion;
+import me.desair.tus.server.TestUtils;
+import me.desair.tus.server.TusFileUploadService;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+import org.testcontainers.containers.GenericContainer;
+
+/**
+ * End-to-end integration test suite verifying {@link TusFileUploadService} backed by {@link
+ * S3StorageService} and {@link S3LockingService} on MinIO using the MinIO Java SDK. Extends {@link
+ * AbstractITTusFileUploadService} to run all Tus 1.0.0 protocol use cases against S3 storage.
+ */
+public class ITS3TusFileUploadService extends AbstractITTusFileUploadService {
+
+ private static GenericContainer> minio;
+ private static MinioClient minioClient;
+ private static final String BUCKET = "test-service-s3-bucket";
+
+ @BeforeClass
+ public static void setUpClass() {
+ org.junit.Assume.assumeTrue(
+ "Container runtime is not available; skipping Testcontainers MinIO test",
+ TestUtils.isContainerRuntimeAvailable());
+
+ minio = TestUtils.createMinioContainer();
+ minio.start();
+
+ minioClient = TestUtils.createMinioClient(minio);
+ TestUtils.createBucket(minioClient, BUCKET);
+ }
+
+ @AfterClass
+ public static void tearDownClass() {
+ if (minio != null) {
+ minio.stop();
+ }
+ }
+
+ @Override
+ protected TusFileUploadService createTusFileUploadService() {
+ return createTusFileUploadService(UPLOAD_URI);
+ }
+
+ @Override
+ protected TusFileUploadService createTusFileUploadService(String uploadUri) {
+ org.junit.Assume.assumeTrue(TestUtils.isContainerRuntimeAvailable());
+
+ S3StorageService s3Storage = new S3StorageService(minioClient, BUCKET);
+ S3LockingService s3Locking = new S3LockingService(minioClient, BUCKET);
+ S3ConcatenationService s3Concat = new S3ConcatenationService(minioClient, BUCKET, s3Storage);
+ s3Storage.setUploadConcatenationService(s3Concat);
+
+ return new TusFileUploadService()
+ .withUploadUri(uploadUri)
+ .withUploadStorageService(s3Storage)
+ .withUploadLockingService(s3Locking)
+ .withMaxUploadSize(1073741824L)
+ .withUploadExpirationPeriod(2L * 24 * 60 * 60 * 1000)
+ .withSupportedProtocolVersions(ProtocolVersion.TUS_1_0_0)
+ .withDownloadFeature()
+ .withChunkedTransferDecoding(true);
+ }
+}
diff --git a/src/test/java/me/desair/tus/server/upload/s3/S3ConcatenationServiceTest.java b/src/test/java/me/desair/tus/server/upload/s3/S3ConcatenationServiceTest.java
new file mode 100644
index 00000000..90a91cbf
--- /dev/null
+++ b/src/test/java/me/desair/tus/server/upload/s3/S3ConcatenationServiceTest.java
@@ -0,0 +1,248 @@
+package me.desair.tus.server.upload.s3;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+import io.minio.ComposeObjectArgs;
+import io.minio.MinioClient;
+import io.minio.PutObjectArgs;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.Paths;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import me.desair.tus.server.exception.UploadNotFoundException;
+import me.desair.tus.server.upload.UploadId;
+import me.desair.tus.server.upload.UploadInfo;
+import me.desair.tus.server.upload.UploadStorageService;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.Mockito;
+
+public class S3ConcatenationServiceTest {
+
+ private MinioClient minioClient;
+ private UploadStorageService storageService;
+ private S3ConcatenationService concatenationService;
+
+ @Before
+ public void setUp() {
+ minioClient = Mockito.mock(MinioClient.class);
+ storageService = Mockito.mock(UploadStorageService.class);
+ concatenationService =
+ new S3ConcatenationService(
+ minioClient,
+ "test-bucket",
+ "tus-uploads/",
+ storageService,
+ Paths.get(System.getProperty("java.io.tmpdir")));
+ }
+
+ @Test
+ public void testConstructorsAndSetters() {
+ S3ConcatenationService service1 = new S3ConcatenationService(minioClient, "test-bucket");
+ S3ConcatenationService service2 =
+ new S3ConcatenationService(minioClient, "test-bucket", storageService);
+ service1.setUploadStorageService(storageService);
+
+ assertNotNull(service1);
+ assertNotNull(service2);
+ }
+
+ @Test
+ public void testMergeEarlyReturnConditions() throws Exception {
+ // Null upload info
+ concatenationService.merge(null);
+
+ // Upload not in progress (offset == length)
+ UploadInfo infoNotInProgress = new UploadInfo();
+ infoNotInProgress.setLength(100L);
+ infoNotInProgress.setOffset(100L);
+ infoNotInProgress.setConcatenationPartIds(Arrays.asList("/part-1"));
+ concatenationService.merge(infoNotInProgress);
+
+ // Null concatenation part IDs
+ UploadInfo infoNullParts = new UploadInfo();
+ infoNullParts.setLength(100L);
+ infoNullParts.setOffset(50L);
+ infoNullParts.setConcatenationPartIds(null);
+ concatenationService.merge(infoNullParts);
+ }
+
+ @Test
+ public void testGetPartialUploads() throws Exception {
+ UploadInfo p1 = new UploadInfo();
+ p1.setId(new UploadId("part-1"));
+ p1.setLength(10L * 1024 * 1024);
+
+ UploadInfo p2 = new UploadInfo();
+ p2.setId(new UploadId("part-2"));
+ p2.setLength(10L * 1024 * 1024);
+
+ Mockito.when(storageService.getUploadInfo("/part-1", "owner-1")).thenReturn(p1);
+ Mockito.when(storageService.getUploadInfo("/part-2", "owner-1")).thenReturn(p2);
+
+ UploadInfo finalUpload = new UploadInfo();
+ finalUpload.setId(new UploadId("final-1"));
+ finalUpload.setOwnerKey("owner-1");
+ finalUpload.setConcatenationPartIds(Arrays.asList("/part-1", "/part-2"));
+
+ List partials = concatenationService.getPartialUploads(finalUpload);
+ assertNotNull(partials);
+ assertEquals(2, partials.size());
+
+ // Empty part list
+ finalUpload.setConcatenationPartIds(Collections.emptyList());
+ assertTrue(concatenationService.getPartialUploads(finalUpload).isEmpty());
+ }
+
+ @Test(expected = UploadNotFoundException.class)
+ public void testGetPartialUploadsChildNotFound() throws Exception {
+ UploadInfo finalUpload = new UploadInfo();
+ finalUpload.setId(new UploadId("final-1"));
+ finalUpload.setOwnerKey("owner-1");
+ finalUpload.setConcatenationPartIds(Arrays.asList("/missing-part"));
+
+ Mockito.when(storageService.getUploadInfo("/missing-part", "owner-1")).thenReturn(null);
+
+ concatenationService.getPartialUploads(finalUpload);
+ }
+
+ @Test
+ public void testMergePartialUploadsServerSideCopy() throws Exception {
+ UploadInfo p1 = new UploadInfo();
+ p1.setId(new UploadId("part-1"));
+ p1.setLength(10L * 1024 * 1024);
+ p1.setOffset(10L * 1024 * 1024);
+ p1.setStorageUploadId("custom-part-1-key");
+
+ Mockito.when(storageService.getUploadInfo("/part-1", "owner-1")).thenReturn(p1);
+
+ UploadInfo finalUpload = new UploadInfo();
+ finalUpload.setId(new UploadId("final-1"));
+ finalUpload.setOwnerKey("owner-1");
+ finalUpload.setConcatenationPartIds(Arrays.asList("/part-1"));
+
+ concatenationService.merge(finalUpload);
+ assertEquals(Long.valueOf(10L * 1024 * 1024), finalUpload.getLength());
+ assertEquals(Long.valueOf(10L * 1024 * 1024), finalUpload.getOffset());
+ assertEquals("tus-uploads/final-1", finalUpload.getStorageUploadId());
+ }
+
+ @Test
+ public void testMergePartialUploadsStreamingReupload() throws Exception {
+ UploadInfo p1 = new UploadInfo();
+ p1.setId(new UploadId("small-part-1"));
+ p1.setLength(100L); // < 5MB minPartSize
+ p1.setOffset(100L);
+
+ Mockito.when(storageService.getUploadInfo("/small-part-1", "owner-1")).thenReturn(p1);
+ Mockito.when(storageService.getUploadedBytes(new UploadId("small-part-1")))
+ .thenReturn(new ByteArrayInputStream(new byte[100]));
+
+ UploadInfo finalUpload = new UploadInfo();
+ finalUpload.setId(new UploadId("final-streaming"));
+ finalUpload.setOwnerKey("owner-1");
+ finalUpload.setConcatenationPartIds(Arrays.asList("/small-part-1"));
+
+ concatenationService.merge(finalUpload);
+ assertEquals(Long.valueOf(100L), finalUpload.getLength());
+ }
+
+ @Test(expected = IOException.class)
+ public void testMergeServerSideCopyFails() throws Exception {
+ UploadInfo p1 = new UploadInfo();
+ p1.setId(new UploadId("part-1"));
+ p1.setLength(10L * 1024 * 1024);
+ p1.setOffset(10L * 1024 * 1024);
+
+ Mockito.when(storageService.getUploadInfo("/part-1", "owner-1")).thenReturn(p1);
+ Mockito.when(minioClient.composeObject(Mockito.any(ComposeObjectArgs.class)))
+ .thenThrow(new RuntimeException("Compose error"));
+
+ UploadInfo finalUpload = new UploadInfo();
+ finalUpload.setId(new UploadId("final-1"));
+ finalUpload.setOwnerKey("owner-1");
+ finalUpload.setConcatenationPartIds(Arrays.asList("/part-1"));
+
+ concatenationService.merge(finalUpload);
+ }
+
+ @Test(expected = IOException.class)
+ public void testMergeStreamingReuploadFails() throws Exception {
+ UploadInfo p1 = new UploadInfo();
+ p1.setId(new UploadId("small-part-1"));
+ p1.setLength(100L);
+ p1.setOffset(100L);
+
+ Mockito.when(storageService.getUploadInfo("/small-part-1", "owner-1")).thenReturn(p1);
+ Mockito.when(minioClient.putObject(Mockito.any(PutObjectArgs.class)))
+ .thenThrow(new RuntimeException("PutObject error"));
+
+ UploadInfo finalUpload = new UploadInfo();
+ finalUpload.setId(new UploadId("final-streaming"));
+ finalUpload.setOwnerKey("owner-1");
+ finalUpload.setConcatenationPartIds(Arrays.asList("/small-part-1"));
+
+ concatenationService.merge(finalUpload);
+ }
+
+ @Test
+ public void testMergeHandlesStorageServiceUpdateException() throws Exception {
+ UploadInfo p1 = new UploadInfo();
+ p1.setId(new UploadId("part-1"));
+ p1.setLength(10L * 1024 * 1024);
+ p1.setOffset(10L * 1024 * 1024);
+
+ Mockito.when(storageService.getUploadInfo("/part-1", "owner-1")).thenReturn(p1);
+ Mockito.doThrow(new UploadNotFoundException("Not found"))
+ .when(storageService)
+ .update(Mockito.any(UploadInfo.class));
+
+ UploadInfo finalUpload = new UploadInfo();
+ finalUpload.setId(new UploadId("final-1"));
+ finalUpload.setOwnerKey("owner-1");
+ finalUpload.setConcatenationPartIds(Arrays.asList("/part-1"));
+
+ concatenationService.merge(finalUpload);
+ }
+
+ @Test
+ public void testGetConcatenatedBytesTriggersMergeWhenStorageUploadIdIsNull() throws Exception {
+ UploadInfo p1 = new UploadInfo();
+ p1.setId(new UploadId("part-1"));
+ p1.setLength(10L * 1024 * 1024);
+ p1.setOffset(10L * 1024 * 1024);
+
+ Mockito.when(storageService.getUploadInfo("/part-1", "owner-1")).thenReturn(p1);
+ Mockito.when(storageService.getUploadedBytes(new UploadId("final-1")))
+ .thenReturn(new ByteArrayInputStream(new byte[10]));
+
+ UploadInfo finalUpload = new UploadInfo();
+ finalUpload.setId(new UploadId("final-1"));
+ finalUpload.setOwnerKey("owner-1");
+ finalUpload.setConcatenationPartIds(Arrays.asList("/part-1"));
+
+ InputStream result = concatenationService.getConcatenatedBytes(finalUpload);
+ assertNotNull(result);
+ }
+
+ @Test(expected = IOException.class)
+ public void testGetConcatenatedBytesWithoutStorageService() throws Exception {
+ S3ConcatenationService standalone = new S3ConcatenationService(minioClient, "test-bucket");
+ UploadInfo info = new UploadInfo();
+ info.setId(new UploadId("concat-1"));
+ info.setStorageUploadId("tus-uploads/concat-1");
+
+ standalone.getConcatenatedBytes(info);
+ }
+
+ @Test
+ public void testGetConcatenatedBytesNull() throws Exception {
+ assertNull(concatenationService.getConcatenatedBytes(null));
+ }
+}
diff --git a/src/test/java/me/desair/tus/server/upload/s3/S3LockingServiceTest.java b/src/test/java/me/desair/tus/server/upload/s3/S3LockingServiceTest.java
new file mode 100644
index 00000000..2f0c1e4b
--- /dev/null
+++ b/src/test/java/me/desair/tus/server/upload/s3/S3LockingServiceTest.java
@@ -0,0 +1,272 @@
+package me.desair.tus.server.upload.s3;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+import io.minio.GetObjectArgs;
+import io.minio.GetObjectResponse;
+import io.minio.ListObjectsArgs;
+import io.minio.MinioClient;
+import io.minio.PutObjectArgs;
+import io.minio.Result;
+import io.minio.StatObjectArgs;
+import io.minio.errors.ErrorResponseException;
+import io.minio.messages.ErrorResponse;
+import io.minio.messages.Item;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.Serializable;
+import java.nio.charset.StandardCharsets;
+import me.desair.tus.server.upload.UploadId;
+import me.desair.tus.server.upload.UploadLock;
+import me.desair.tus.server.util.InterruptibleInputStream;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.Mockito;
+
+public class S3LockingServiceTest {
+
+ private MinioClient minioClient;
+ private S3LockingService lockingService;
+
+ @Before
+ public void setUp() {
+ minioClient = Mockito.mock(MinioClient.class);
+ lockingService = new S3LockingService(minioClient, "test-bucket");
+ }
+
+ @Test
+ public void testLockUploadByUriSuccessAndLockMethods() throws Exception {
+ Mockito.when(minioClient.putObject(Mockito.any(PutObjectArgs.class))).thenReturn(null);
+
+ UploadLock lock =
+ lockingService.lockUploadByUri("/files/upload/24249a5b-01a4-4bf8-b67a-364273bb5a2e");
+ assertNotNull(lock);
+ assertEquals("/files/upload/24249a5b-01a4-4bf8-b67a-364273bb5a2e", lock.getUploadUri());
+
+ if (lock instanceof S3UploadLock) {
+ S3UploadLock s3Lock = (S3UploadLock) lock;
+ assertNotNull(s3Lock.getHolderId());
+ s3Lock.release();
+ }
+
+ lock.close();
+ }
+
+ @Test
+ public void testLockUploadByUriInvalidUri() throws Exception {
+ UploadLock lock = lockingService.lockUploadByUri("/invalid-uri");
+ assertNull(lock);
+ }
+
+ @Test
+ public void testIsLocked() throws Exception {
+ assertFalse(lockingService.isLocked((UploadId) null));
+
+ // Case 1: Lock object missing -> returns false
+ ErrorResponse errorResponse = Mockito.mock(ErrorResponse.class);
+ Mockito.when(errorResponse.code()).thenReturn("NoSuchKey");
+ ErrorResponseException noSuchKeyEx = new ErrorResponseException(errorResponse, null, null);
+
+ Mockito.when(minioClient.getObject(Mockito.any(GetObjectArgs.class))).thenThrow(noSuchKeyEx);
+
+ UploadId uploadId = new UploadId("24249a5b-01a4-4bf8-b67a-364273bb5a2e");
+ assertFalse(lockingService.isLocked(uploadId));
+
+ // Case 2: Active lock object present -> returns true
+ long futureExpiry = System.currentTimeMillis() + 600000L;
+ String lockJson = "{\"holderId\":\"h1\",\"expiresAt\":" + futureExpiry + "}";
+
+ Mockito.when(minioClient.getObject(Mockito.any(GetObjectArgs.class)))
+ .thenAnswer(
+ invocation ->
+ new GetObjectResponse(
+ null,
+ "test-bucket",
+ "us-east-1",
+ "tus-locks/24249a5b.lock",
+ new ByteArrayInputStream(lockJson.getBytes(StandardCharsets.UTF_8))));
+
+ assertTrue(lockingService.isLocked(uploadId));
+ }
+
+ @Test
+ public void testIsLockedReturnsFalseOnGenericMinioException() throws Exception {
+ Mockito.when(minioClient.getObject(Mockito.any(GetObjectArgs.class)))
+ .thenThrow(new RuntimeException("GetObject failure"));
+
+ UploadId uploadId = new UploadId("24249a5b-01a4-4bf8-b67a-364273bb5a2e");
+ assertFalse(lockingService.isLocked(uploadId));
+ }
+
+ @Test
+ public void testIsLockedReturnsFalseOnErrorResponseNon404() throws Exception {
+ ErrorResponse errorResponse = Mockito.mock(ErrorResponse.class);
+ Mockito.when(errorResponse.code()).thenReturn("AccessDenied");
+ ErrorResponseException accessDeniedEx = new ErrorResponseException(errorResponse, null, null);
+
+ Mockito.when(minioClient.getObject(Mockito.any(GetObjectArgs.class))).thenThrow(accessDeniedEx);
+
+ UploadId uploadId = new UploadId("24249a5b-01a4-4bf8-b67a-364273bb5a2e");
+ assertFalse(lockingService.isLocked(uploadId));
+ }
+
+ @Test
+ public void testLockAcquisitionFailures() throws Exception {
+ // Generic Exception during putObject
+ Mockito.when(minioClient.putObject(Mockito.any(PutObjectArgs.class)))
+ .thenThrow(new RuntimeException("S3 unreachable"));
+
+ try {
+ lockingService.lockUploadByUri("/files/upload/24249a5b-01a4-4bf8-b67a-364273bb5a2e");
+ } catch (Exception expected) {
+ }
+ }
+
+ @Test
+ public void testRegisterInputStreamAndRequestLockReleaseWithInterruption() throws Exception {
+ lockingService.setIdFactory(new me.desair.tus.server.upload.UuidUploadIdFactory());
+
+ // Test with InterruptibleInputStream
+ InterruptibleInputStream interruptibleStream = Mockito.mock(InterruptibleInputStream.class);
+
+ lockingService.registerInputStream(
+ "/files/upload/24249a5b-01a4-4bf8-b67a-364273bb5a2e", interruptibleStream);
+
+ // Mock statObject to succeed (stop key exists)
+ Mockito.when(minioClient.statObject(Mockito.any(StatObjectArgs.class)))
+ .thenReturn(Mockito.mock(io.minio.StatObjectResponse.class));
+
+ lockingService.requestLockRelease("/files/upload/24249a5b-01a4-4bf8-b67a-364273bb5a2e");
+ Mockito.verify(interruptibleStream).interrupt();
+
+ // Test with standard InputStream
+ InputStream standardStream = Mockito.mock(InputStream.class);
+ lockingService.registerInputStream(
+ "/files/upload/24249a5b-01a4-4bf8-b67a-364273bb5a2e", standardStream);
+ lockingService.requestLockRelease("/files/upload/24249a5b-01a4-4bf8-b67a-364273bb5a2e");
+ Mockito.verify(standardStream).close();
+
+ lockingService.requestLockRelease(null);
+ }
+
+ @Test
+ public void testCleanupStaleLocksWithExpiredItem() throws Exception {
+ Item item = Mockito.mock(Item.class);
+ Mockito.when(item.objectName()).thenReturn("tus-locks/expired.lock");
+
+ Result- result = new Result<>(item);
+ Iterable> results = java.util.Collections.singletonList(result);
+
+ Mockito.when(minioClient.listObjects(Mockito.any(ListObjectsArgs.class))).thenReturn(results);
+
+ // Expired lock JSON
+ String expiredJson =
+ "{\"holderId\":\"h1\",\"expiresAt\":" + (System.currentTimeMillis() - 1000) + "}";
+ GetObjectResponse response =
+ new GetObjectResponse(
+ null,
+ "test-bucket",
+ "us-east-1",
+ "tus-locks/expired.lock",
+ new ByteArrayInputStream(expiredJson.getBytes(StandardCharsets.UTF_8)));
+
+ Mockito.when(minioClient.getObject(Mockito.any(GetObjectArgs.class))).thenReturn(response);
+
+ lockingService.cleanupStaleLocks();
+ }
+
+ @Test(expected = IOException.class)
+ public void testCleanupStaleLocksThrowsIOExceptionOnMinioFailure() throws Exception {
+ Mockito.when(minioClient.listObjects(Mockito.any(ListObjectsArgs.class)))
+ .thenThrow(new RuntimeException("ListObjects failure"));
+
+ lockingService.cleanupStaleLocks();
+ }
+
+ @Test(expected = me.desair.tus.server.exception.UploadAlreadyLockedException.class)
+ public void testLockUploadByUriThrowsUploadAlreadyLockedExceptionWhenPutObjectFails()
+ throws Exception {
+ Mockito.when(minioClient.putObject(Mockito.any(PutObjectArgs.class)))
+ .thenThrow(new RuntimeException("PutObject failure"));
+
+ lockingService.lockUploadByUri("/files/upload/24249a5b-01a4-4bf8-b67a-364273bb5a2e");
+ }
+
+ @Test
+ public void testWriteStopSignalHandlesMinioException() throws Exception {
+ lockingService.setIdFactory(new me.desair.tus.server.upload.UuidUploadIdFactory());
+ Mockito.when(minioClient.putObject(Mockito.any(PutObjectArgs.class)))
+ .thenThrow(new RuntimeException("PutObject failure for stop signal"));
+
+ lockingService.requestLockRelease("/files/upload/24249a5b-01a4-4bf8-b67a-364273bb5a2e");
+ }
+
+ @Test
+ public void testDeleteObjectQuietlyHandlesMinioException() throws Exception {
+ Mockito.doThrow(new RuntimeException("RemoveObject failure"))
+ .when(minioClient)
+ .removeObject(Mockito.any(io.minio.RemoveObjectArgs.class));
+
+ UploadLock lock =
+ lockingService.lockUploadByUri("/files/upload/24249a5b-01a4-4bf8-b67a-364273bb5a2e");
+ if (lock != null) {
+ lock.close();
+ }
+ }
+
+ @Test
+ public void testCheckStopSignalForEntryWithNullUploadId() throws Exception {
+ lockingService.setIdFactory(
+ new me.desair.tus.server.upload.UploadIdFactory() {
+ @Override
+ public me.desair.tus.server.upload.UploadId readUploadId(String text) {
+ return null;
+ }
+
+ @Override
+ public me.desair.tus.server.upload.UploadId createId() {
+ return null;
+ }
+
+ @Override
+ public String getUploadUri() {
+ return "/";
+ }
+
+ @Override
+ protected Serializable getIdValueIfValid(String extractedUrlId) {
+ throw new UnsupportedOperationException("Unimplemented method 'getIdValueIfValid'");
+ }
+ });
+ InputStream mockStream = Mockito.mock(InputStream.class);
+ lockingService.registerInputStream("/invalid-uri", mockStream);
+ lockingService.requestLockRelease("/invalid-uri");
+ }
+
+ @Test
+ public void testInterruptStreamStandardStreamCloseException() throws Exception {
+ lockingService.setIdFactory(new me.desair.tus.server.upload.UuidUploadIdFactory());
+ InputStream brokenStream = Mockito.mock(InputStream.class);
+ Mockito.doThrow(new IOException("Close error")).when(brokenStream).close();
+
+ lockingService.registerInputStream(
+ "/files/upload/24249a5b-01a4-4bf8-b67a-364273bb5a2e", brokenStream);
+ lockingService.requestLockRelease("/files/upload/24249a5b-01a4-4bf8-b67a-364273bb5a2e");
+ }
+
+ @Test
+ public void testSanitizePrefixNullOrEmpty() throws Exception {
+ S3LockingService serviceWithEmptyPrefix =
+ new S3LockingService(minioClient, "test-bucket", "", 30000L, 0L);
+ assertNotNull(serviceWithEmptyPrefix);
+
+ S3LockingService serviceWithNullPrefix =
+ new S3LockingService(minioClient, "test-bucket", null, 30000L, 0L);
+ assertNotNull(serviceWithNullPrefix);
+ }
+}
diff --git a/src/test/java/me/desair/tus/server/upload/s3/S3StorageServiceTest.java b/src/test/java/me/desair/tus/server/upload/s3/S3StorageServiceTest.java
new file mode 100644
index 00000000..22d03209
--- /dev/null
+++ b/src/test/java/me/desair/tus/server/upload/s3/S3StorageServiceTest.java
@@ -0,0 +1,1261 @@
+package me.desair.tus.server.upload.s3;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import io.minio.ComposeObjectArgs;
+import io.minio.GetObjectArgs;
+import io.minio.GetObjectResponse;
+import io.minio.ListObjectsArgs;
+import io.minio.MinioClient;
+import io.minio.PutObjectArgs;
+import io.minio.Result;
+import io.minio.StatObjectArgs;
+import io.minio.StatObjectResponse;
+import io.minio.errors.ErrorResponseException;
+import io.minio.messages.ErrorResponse;
+import io.minio.messages.Item;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Arrays;
+import me.desair.tus.server.checksum.ChecksumAlgorithm;
+import me.desair.tus.server.exception.MinUploadLengthNotReachedException;
+import me.desair.tus.server.upload.UploadId;
+import me.desair.tus.server.upload.UploadInfo;
+import me.desair.tus.server.upload.UploadLockingService;
+import me.desair.tus.server.util.UploadInfoJsonSerializer;
+import org.junit.Before;
+import org.junit.Test;
+
+public class S3StorageServiceTest {
+
+ private MinioClient minioClient;
+ private S3StorageService storageService;
+
+ @Before
+ public void setUp() {
+ minioClient = mock(MinioClient.class);
+ storageService = new S3StorageService(minioClient, "test-bucket");
+ }
+
+ @Test
+ public void testCreateUpload() throws Exception {
+ UploadInfo info = new UploadInfo();
+ info.setId(new UploadId("24249a5b-01a4-4bf8-b67a-364273bb5a2e"));
+ info.setLength(1024L);
+
+ UploadInfo created = storageService.create(info, "owner-1");
+
+ assertNotNull(created);
+ assertEquals("24249a5b-01a4-4bf8-b67a-364273bb5a2e", created.getStorageUploadId());
+ assertEquals("owner-1", created.getOwnerKey());
+ assertEquals(
+ "tus-uploads/24249a5b-01a4-4bf8-b67a-364273bb5a2e", storageService.getS3ObjectKey(created));
+ }
+
+ @Test
+ public void testNullTemporaryDirectoryConstructor() {
+ java.nio.file.Path nullPath = null;
+ S3StorageService serviceWithNullTmp =
+ new S3StorageService(
+ minioClient,
+ "test-bucket",
+ "tus-uploads/",
+ "tus-uploads/",
+ "checksums/",
+ "locks/",
+ nullPath);
+ assertNotNull(serviceWithNullTmp);
+ }
+
+ @Test
+ public void testGetS3ObjectKeyByUri() throws Exception {
+ UploadInfo info = new UploadInfo();
+ info.setId(new UploadId("24249a5b-01a4-4bf8-b67a-364273bb5a2e"));
+ info.setStorageUploadId("tus-uploads/custom-key-123");
+ info.setOwnerKey("owner-1");
+
+ String json = UploadInfoJsonSerializer.serialize(info);
+ when(minioClient.getObject(any(GetObjectArgs.class)))
+ .thenAnswer(invocation -> mockGetObjectResponse(json.getBytes()));
+
+ storageService.setIdFactory(new me.desair.tus.server.upload.UuidUploadIdFactory());
+
+ String keyByUri =
+ storageService.getS3ObjectKey("/files/upload/24249a5b-01a4-4bf8-b67a-364273bb5a2e");
+ assertEquals("tus-uploads/custom-key-123", keyByUri);
+
+ String keyByUriAndOwner =
+ storageService.getS3ObjectKey(
+ "/files/upload/24249a5b-01a4-4bf8-b67a-364273bb5a2e", "owner-1");
+ assertEquals("tus-uploads/custom-key-123", keyByUriAndOwner);
+ }
+
+ @Test
+ public void testGetS3ObjectKeyByUriExceptions() throws Exception {
+ when(minioClient.getObject(any(GetObjectArgs.class)))
+ .thenThrow(new RuntimeException("MinIO failure"));
+
+ storageService.setIdFactory(new me.desair.tus.server.upload.UuidUploadIdFactory());
+
+ assertNull(storageService.getS3ObjectKey("/files/upload/24249a5b-01a4-4bf8-b67a-364273bb5a2e"));
+ assertNull(
+ storageService.getS3ObjectKey(
+ "/files/upload/24249a5b-01a4-4bf8-b67a-364273bb5a2e", "owner-1"));
+ }
+
+ @Test(expected = me.desair.tus.server.exception.UploadNotFoundException.class)
+ public void testGetUploadedBytesNotFound() throws Exception {
+ ErrorResponse errorResponse = mock(ErrorResponse.class);
+ when(errorResponse.code()).thenReturn("NoSuchKey");
+ ErrorResponseException ex = new ErrorResponseException(errorResponse, null, null);
+ when(minioClient.getObject(any(GetObjectArgs.class))).thenThrow(ex);
+
+ storageService.getUploadedBytes(new UploadId("missing-123"));
+ }
+
+ @Test
+ public void testGetUploadedBytesDuplicate() throws Exception {
+ UploadInfo child = new UploadInfo();
+ child.setId(new UploadId("child-123"));
+ child.setDuplicatesUploadId(new UploadId("parent-456"));
+
+ UploadInfo parent = new UploadInfo();
+ parent.setId(new UploadId("parent-456"));
+
+ String childJson = UploadInfoJsonSerializer.serialize(child);
+ String parentJson = UploadInfoJsonSerializer.serialize(parent);
+
+ when(minioClient.getObject(any(GetObjectArgs.class)))
+ .thenAnswer(
+ invocation -> {
+ GetObjectArgs args = invocation.getArgument(0);
+ if (args.object().contains("child-123")) {
+ return mockGetObjectResponse(childJson.getBytes());
+ }
+ if (args.object().contains("parent-456.info")) {
+ return mockGetObjectResponse(parentJson.getBytes());
+ }
+ return mockGetObjectResponse("parent-data".getBytes());
+ });
+
+ InputStream stream = storageService.getUploadedBytes(new UploadId("child-123"));
+ assertNotNull(stream);
+ }
+
+ @Test
+ public void testGetUploadedBytesConcatenatedUnmerged() throws Exception {
+ UploadInfo info = new UploadInfo();
+ info.setId(new UploadId("concat-123"));
+ info.setUploadType(me.desair.tus.server.upload.UploadType.CONCATENATED);
+ info.setStorageUploadId(null);
+
+ UploadInfo mergedInfo = new UploadInfo();
+ mergedInfo.setId(new UploadId("concat-123"));
+ mergedInfo.setStorageUploadId("tus-uploads/concat-123");
+
+ String jsonBefore = UploadInfoJsonSerializer.serialize(info);
+ String jsonAfter = UploadInfoJsonSerializer.serialize(mergedInfo);
+
+ java.util.concurrent.atomic.AtomicInteger infoCallCount =
+ new java.util.concurrent.atomic.AtomicInteger();
+
+ when(minioClient.getObject(any(GetObjectArgs.class)))
+ .thenAnswer(
+ invocation -> {
+ GetObjectArgs args = invocation.getArgument(0);
+ if (args.object().endsWith(".info")) {
+ if (infoCallCount.getAndIncrement() == 0) {
+ return mockGetObjectResponse(jsonBefore.getBytes());
+ }
+ return mockGetObjectResponse(jsonAfter.getBytes());
+ }
+ return mockGetObjectResponse("merged-bytes".getBytes());
+ });
+
+ S3ConcatenationService mockConcat = mock(S3ConcatenationService.class);
+ storageService.setUploadConcatenationService(mockConcat);
+
+ InputStream stream = storageService.getUploadedBytes(new UploadId("concat-123"));
+ assertNotNull(stream);
+ }
+
+ @Test(expected = me.desair.tus.server.exception.MaxAppendSizeExceededException.class)
+ public void testAppendExceedsMaxAppendSizeLimit() throws Exception {
+ UploadInfo info = new UploadInfo();
+ info.setId(new UploadId("24249a5b-01a4-4bf8-b67a-364273bb5a2e"));
+ info.setLength(10000L);
+
+ String json = UploadInfoJsonSerializer.serialize(info);
+ when(minioClient.getObject(any(GetObjectArgs.class)))
+ .thenAnswer(invocation -> mockGetObjectResponse(json.getBytes()));
+
+ storageService.setMaxAppendSize(50L);
+ storageService.append(info, new ByteArrayInputStream(new byte[100]));
+ }
+
+ @Test(expected = MinUploadLengthNotReachedException.class)
+ public void testAppendBelowMinSize() throws Exception {
+ UploadInfo info = new UploadInfo();
+ info.setId(new UploadId("24249a5b-01a4-4bf8-b67a-364273bb5a2e"));
+ info.setLength(1000L);
+
+ String json = UploadInfoJsonSerializer.serialize(info);
+ GetObjectResponse stream = mockGetObjectResponse(json.getBytes());
+
+ when(minioClient.getObject(any(GetObjectArgs.class))).thenReturn(stream);
+
+ storageService.setMinSize(2000L);
+ storageService.append(info, new ByteArrayInputStream(new byte[100]));
+ }
+
+ @Test(expected = IOException.class)
+ public void testAppendThrowsIOExceptionOnStreamError() throws Exception {
+ UploadInfo info = new UploadInfo();
+ info.setId(new UploadId("24249a5b-01a4-4bf8-b67a-364273bb5a2e"));
+ info.setLength(1000L);
+
+ String json = UploadInfoJsonSerializer.serialize(info);
+ when(minioClient.getObject(any(GetObjectArgs.class)))
+ .thenAnswer(invocation -> mockGetObjectResponse(json.getBytes()));
+
+ InputStream brokenStream = mock(InputStream.class);
+ when(brokenStream.read(any(byte[].class))).thenThrow(new IOException("Read failed"));
+
+ storageService.append(info, brokenStream);
+ }
+
+ @Test
+ public void testGetUploadInfoReturnsNullForMissingKey() throws Exception {
+ ErrorResponse errorResponse = mock(ErrorResponse.class);
+ when(errorResponse.code()).thenReturn("NoSuchKey");
+
+ ErrorResponseException ex = new ErrorResponseException(errorResponse, null, null);
+
+ when(minioClient.getObject(any(GetObjectArgs.class))).thenThrow(ex);
+
+ UploadInfo result =
+ storageService.getUploadInfo(new UploadId("24249a5b-01a4-4bf8-b67a-364273bb5a2e"));
+ assertNull(result);
+ }
+
+ @Test(expected = IOException.class)
+ public void testGetUploadInfoThrowsIOExceptionOnGenericException() throws Exception {
+ when(minioClient.getObject(any(GetObjectArgs.class)))
+ .thenThrow(new RuntimeException("Storage failure"));
+
+ storageService.getUploadInfo(new UploadId("24249a5b-01a4-4bf8-b67a-364273bb5a2e"));
+ }
+
+ @Test(expected = IOException.class)
+ public void testGetUploadInfoThrowsIOExceptionOnErrorResponseNon404() throws Exception {
+ ErrorResponse errorResponse = mock(ErrorResponse.class);
+ when(errorResponse.code()).thenReturn("AccessDenied");
+
+ ErrorResponseException ex = new ErrorResponseException(errorResponse, null, null);
+ when(minioClient.getObject(any(GetObjectArgs.class))).thenThrow(ex);
+
+ storageService.getUploadInfo(new UploadId("24249a5b-01a4-4bf8-b67a-364273bb5a2e"));
+ }
+
+ @Test
+ public void testCopyUploadToAndRemoveLastBytes() throws Exception {
+ UploadInfo info = new UploadInfo();
+ info.setId(new UploadId("24249a5b-01a4-4bf8-b67a-364273bb5a2e"));
+ info.setLength(100L);
+ info.setOffset(100L);
+
+ String json = UploadInfoJsonSerializer.serialize(info);
+ byte[] payload = new byte[100];
+
+ when(minioClient.getObject(any(GetObjectArgs.class)))
+ .thenAnswer(
+ invocation -> {
+ GetObjectArgs args = invocation.getArgument(0);
+ if (args.object().endsWith(".info")) {
+ return mockGetObjectResponse(json.getBytes());
+ }
+ return mockGetObjectResponse(payload);
+ });
+
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ storageService.copyUploadTo(info, baos);
+ assertEquals(100, baos.size());
+
+ when(minioClient.statObject(any())).thenReturn(mock(StatObjectResponse.class));
+
+ // Verify removeLastNumberOfBytes updates offset
+ storageService.removeLastNumberOfBytes(info, 5);
+ assertEquals(Long.valueOf(95L), info.getOffset());
+
+ // Test removeLastNumberOfBytes with byteCount <= 0
+ storageService.removeLastNumberOfBytes(info, 0);
+ }
+
+ @Test
+ public void testTruncateIncompletePartPartial() throws Exception {
+ UploadInfo info = new UploadInfo();
+ info.setId(new UploadId("24249a5b-01a4-4bf8-b67a-364273bb5a2e"));
+ info.setOffset(100L);
+ info.setLength(1000L);
+
+ byte[] partBytes = new byte[100];
+ StatObjectResponse mockHead = mock(StatObjectResponse.class);
+ when(mockHead.size()).thenReturn(100L);
+
+ when(minioClient.statObject(any(StatObjectArgs.class))).thenReturn(mockHead);
+ when(minioClient.getObject(any(GetObjectArgs.class)))
+ .thenAnswer(invocation -> mockGetObjectResponse(partBytes));
+
+ storageService.removeLastNumberOfBytes(info, 5);
+ assertEquals(Long.valueOf(95L), info.getOffset());
+ }
+
+ @Test
+ public void testCalculateAndSetOffsetWhenCompletedObjectExists() throws Exception {
+ String json = "{\"id\":\"24249a5b-01a4-4bf8-b67a-364273bb5a2e\",\"length\":1000}";
+ StatObjectResponse mockHead = mock(StatObjectResponse.class);
+ when(mockHead.size()).thenReturn(1000L);
+
+ when(minioClient.getObject(any(GetObjectArgs.class)))
+ .thenAnswer(invocation -> mockGetObjectResponse(json.getBytes()));
+ when(minioClient.statObject(any(StatObjectArgs.class))).thenReturn(mockHead);
+
+ UploadInfo fetched =
+ storageService.getUploadInfo(new UploadId("24249a5b-01a4-4bf8-b67a-364273bb5a2e"));
+ assertNotNull(fetched);
+ }
+
+ @Test
+ public void testGetUploadInfoWithNullOffsetCalculatesOffset() throws Exception {
+ String json =
+ "{\"id\":\"24249a5b-01a4-4bf8-b67a-364273bb5a2e\",\"length\":1000,\"offset\":null}";
+ StatObjectResponse mockHead = mock(StatObjectResponse.class);
+ when(mockHead.size()).thenReturn(500L);
+
+ when(minioClient.getObject(any(GetObjectArgs.class)))
+ .thenAnswer(invocation -> mockGetObjectResponse(json.getBytes()));
+
+ when(minioClient.statObject(any(StatObjectArgs.class)))
+ .thenAnswer(
+ invocation -> {
+ StatObjectArgs args = invocation.getArgument(0);
+
+ // Check if objects ends with ".part"
+ if (args.object().endsWith(".part")) {
+ // Simulate that the part does not exist by throwing a NoSuchKey exception
+ ErrorResponse errorResponse = mock(ErrorResponse.class);
+ when(errorResponse.code()).thenReturn("NoSuchKey");
+ throw new ErrorResponseException(errorResponse, null, null);
+ }
+
+ return mockHead;
+ });
+
+ UploadInfo fetched =
+ storageService.getUploadInfo(new UploadId("24249a5b-01a4-4bf8-b67a-364273bb5a2e"));
+ assertNotNull(fetched);
+ assertEquals(Long.valueOf(500L), fetched.getOffset());
+ }
+
+ @Test
+ public void testAppendCompletingUploadWithLeftoverPart() throws Exception {
+ UploadInfo info = new UploadInfo();
+ info.setId(new UploadId("24249a5b-01a4-4bf8-b67a-364273bb5a2e"));
+ info.setLength(100L);
+ info.setOffset(50L);
+
+ String jsonBefore = UploadInfoJsonSerializer.serialize(info);
+ info.setOffset(100L);
+ String jsonAfter = UploadInfoJsonSerializer.serialize(info);
+ info.setOffset(50L);
+
+ byte[] payload = new byte[50];
+ java.util.concurrent.atomic.AtomicInteger infoCallCount =
+ new java.util.concurrent.atomic.AtomicInteger();
+
+ when(minioClient.getObject(any(GetObjectArgs.class)))
+ .thenAnswer(
+ invocation -> {
+ GetObjectArgs args = invocation.getArgument(0);
+ if (args.object().endsWith(".info")) {
+ if (infoCallCount.getAndIncrement() == 0) {
+ return mockGetObjectResponse(jsonBefore.getBytes());
+ }
+ return mockGetObjectResponse(jsonAfter.getBytes());
+ }
+ return mockGetObjectResponse(payload);
+ });
+
+ when(minioClient.statObject(any(StatObjectArgs.class)))
+ .thenAnswer(
+ invocation -> {
+ StatObjectArgs args = invocation.getArgument(0);
+ if (args.object().endsWith(".part")) {
+ StatObjectResponse resp = mock(StatObjectResponse.class);
+ when(resp.size()).thenReturn(50L);
+ return resp;
+ }
+ ErrorResponse errorResponse = mock(ErrorResponse.class);
+ when(errorResponse.code()).thenReturn("NoSuchKey");
+ throw new ErrorResponseException(errorResponse, null, null);
+ });
+
+ storageService.append(info, new ByteArrayInputStream(payload));
+ }
+
+ @Test
+ public void testFetchS3ByteStreamWithOffsetAndLengthRange() throws Exception {
+ UploadInfo info = new UploadInfo();
+ info.setId(new UploadId("24249a5b-01a4-4bf8-b67a-364273bb5a2e"));
+ String json = UploadInfoJsonSerializer.serialize(info);
+
+ when(minioClient.getObject(any(GetObjectArgs.class)))
+ .thenAnswer(
+ invocation -> {
+ GetObjectArgs args = invocation.getArgument(0);
+ if (args.object().endsWith(".info")) {
+ return mockGetObjectResponse(json.getBytes());
+ }
+ return mockGetObjectResponse("ranged-payload".getBytes());
+ });
+
+ InputStream stream = storageService.getUploadedBytes(info.getId());
+ assertNotNull(stream);
+ }
+
+ @Test(expected = IOException.class)
+ public void testGetUploadInfoByChecksumThrowsIOExceptionOnErrorResponseNon404() throws Exception {
+ storageService.setUploadDeduplicationEnabled(true);
+
+ ErrorResponse errorResponse = mock(ErrorResponse.class);
+ when(errorResponse.code()).thenReturn("AccessDenied");
+ ErrorResponseException ex = new ErrorResponseException(errorResponse, null, null);
+
+ when(minioClient.getObject(any(GetObjectArgs.class))).thenThrow(ex);
+
+ storageService.getUploadInfoByChecksum("abc123hash", ChecksumAlgorithm.SHA256);
+ }
+
+ @Test(expected = IOException.class)
+ public void testGetUploadInfoByChecksumThrowsIOExceptionOnGenericException() throws Exception {
+ storageService.setUploadDeduplicationEnabled(true);
+
+ when(minioClient.getObject(any(GetObjectArgs.class)))
+ .thenThrow(new RuntimeException("MinIO failure"));
+
+ storageService.getUploadInfoByChecksum("abc123hash", ChecksumAlgorithm.SHA256);
+ }
+
+ @Test
+ public void testDeduplicationChecksumLookupSelfCleaningWhenParentMissing() throws Exception {
+ storageService.setUploadDeduplicationEnabled(true);
+
+ when(minioClient.getObject(any(GetObjectArgs.class)))
+ .thenReturn(mockGetObjectResponse("stale-parent-456".getBytes()));
+
+ ErrorResponse errorResponse = mock(ErrorResponse.class);
+ when(errorResponse.code()).thenReturn("NoSuchKey");
+ ErrorResponseException noSuchKey = new ErrorResponseException(errorResponse, null, null);
+ when(minioClient.statObject(any(StatObjectArgs.class))).thenThrow(noSuchKey);
+
+ UploadInfo match =
+ storageService.getUploadInfoByChecksum("stalehash", ChecksumAlgorithm.SHA256);
+ assertNull(match);
+ }
+
+ @Test
+ public void testDeduplicationChecksumLookup() throws Exception {
+ storageService.setUploadDeduplicationEnabled(true);
+
+ UploadInfo parentInfo = new UploadInfo();
+ parentInfo.setId(new UploadId("parent-123"));
+ parentInfo.setLength(5000L);
+
+ String json = UploadInfoJsonSerializer.serialize(parentInfo);
+
+ java.util.Map objectData = new java.util.HashMap<>();
+ objectData.put("checksums/sha256/abc123hash", "parent-123".getBytes());
+ objectData.put("tus-uploads/checksums/sha256/abc123hash", "parent-123".getBytes());
+ objectData.put("tus-uploads/parent-123.info", json.getBytes());
+
+ when(minioClient.getObject(any(GetObjectArgs.class)))
+ .thenAnswer(
+ invocation -> {
+ GetObjectArgs args = invocation.getArgument(0);
+ byte[] data = objectData.get(args.object());
+ if (data != null) {
+ return mockGetObjectResponse(data);
+ }
+ return mockGetObjectResponse(json.getBytes());
+ });
+
+ when(minioClient.statObject(any(StatObjectArgs.class)))
+ .thenReturn(mock(StatObjectResponse.class));
+
+ UploadInfo match =
+ storageService.getUploadInfoByChecksum("abc123hash", ChecksumAlgorithm.SHA256);
+ assertNotNull(match);
+ assertEquals(new UploadId("parent-123"), match.getId());
+ }
+
+ @Test
+ public void testConfigurationSettersAndGetters() {
+ storageService.setMaxUploadSize(5000L);
+ assertEquals(5000L, storageService.getMaxUploadSize());
+
+ storageService.setMaxAppendSize(3000L);
+ assertEquals(Long.valueOf(3000L), storageService.getMaxAppendSize());
+
+ storageService.setMinAppendSize(100L);
+ assertEquals(Long.valueOf(100L), storageService.getMinAppendSize());
+
+ storageService.setMinSize(50L);
+ assertEquals(Long.valueOf(50L), storageService.getMinSize());
+
+ storageService.setUploadExpirationPeriod(86400000L);
+ assertEquals(Long.valueOf(86400000L), storageService.getUploadExpirationPeriod());
+
+ storageService.setUploadDeduplicationEnabled(true);
+ assertTrue(storageService.isUploadDeduplicationEnabled());
+
+ storageService.setIdFactory(new me.desair.tus.server.upload.UuidUploadIdFactory());
+
+ S3ConcatenationService concat = new S3ConcatenationService(minioClient, "test-bucket");
+ storageService.setUploadConcatenationService(concat);
+ assertEquals(concat, storageService.getUploadConcatenationService());
+
+ assertNotNull(storageService.getUploadUri());
+ }
+
+ @Test
+ public void testNullUploadOperations() throws Exception {
+ assertNull(storageService.getUploadInfo((UploadId) null));
+ assertNull(storageService.getUploadInfo((String) null, null));
+ assertNull(storageService.getS3ObjectKey((UploadInfo) null));
+ assertNull(storageService.getS3ObjectKey((String) null));
+
+ storageService.update(null);
+ storageService.removeLastNumberOfBytes(null, 100);
+ storageService.terminateUpload(null);
+
+ assertNull(storageService.getUploadInfoByChecksum(null, null));
+ assertNull(storageService.getUploadInfoByChecksum("abc", ChecksumAlgorithm.SHA256));
+ }
+
+ @Test(expected = me.desair.tus.server.exception.MinAppendSizeNotMetException.class)
+ public void testAppendThrowsMinAppendSizeNotMetException() throws Exception {
+ UploadInfo info = new UploadInfo();
+ info.setId(new UploadId("24249a5b-01a4-4bf8-b67a-364273bb5a2e"));
+ info.setLength(1000L);
+
+ String json = UploadInfoJsonSerializer.serialize(info);
+ when(minioClient.getObject(any(GetObjectArgs.class)))
+ .thenAnswer(invocation -> mockGetObjectResponse(json.getBytes()));
+
+ storageService.setMinAppendSize(500L);
+ storageService.append(info, new ByteArrayInputStream(new byte[100]));
+ }
+
+ @Test(expected = me.desair.tus.server.exception.MaxUploadLengthExceededException.class)
+ public void testAppendThrowsMaxUploadLengthExceededException() throws Exception {
+ UploadInfo info = new UploadInfo();
+ info.setId(new UploadId("24249a5b-01a4-4bf8-b67a-364273bb5a2e"));
+ info.setLength(2000L);
+
+ String json = UploadInfoJsonSerializer.serialize(info);
+ when(minioClient.getObject(any(GetObjectArgs.class)))
+ .thenAnswer(invocation -> mockGetObjectResponse(json.getBytes()));
+
+ storageService.setMaxUploadSize(1000L);
+ storageService.append(info, new ByteArrayInputStream(new byte[100]));
+ }
+
+ @Test(expected = me.desair.tus.server.exception.UploadNotFoundException.class)
+ public void testGetUploadedBytesByUriNotFoundThrowsException() throws Exception {
+ ErrorResponse errorResponse = mock(ErrorResponse.class);
+ when(errorResponse.code()).thenReturn("NoSuchKey");
+ ErrorResponseException ex = new ErrorResponseException(errorResponse, null, null);
+ when(minioClient.getObject(any(GetObjectArgs.class))).thenThrow(ex);
+
+ storageService.getUploadedBytes("/files/upload/non-existent-id", null);
+ }
+
+ @Test(expected = me.desair.tus.server.exception.UploadNotFoundException.class)
+ public void testAppendByUploadIdNotFoundThrowsException() throws Exception {
+ ErrorResponse errorResponse = mock(ErrorResponse.class);
+ when(errorResponse.code()).thenReturn("NoSuchKey");
+ ErrorResponseException ex = new ErrorResponseException(errorResponse, null, null);
+ when(minioClient.getObject(any(GetObjectArgs.class))).thenThrow(ex);
+
+ UploadInfo info = new UploadInfo();
+ info.setId(new UploadId("non-existent-id"));
+
+ storageService.append(info, new ByteArrayInputStream(new byte[100]));
+ }
+
+ @Test(expected = me.desair.tus.server.exception.UploadNotFoundException.class)
+ public void testCopyUploadToNotFoundThrowsUploadNotFoundException() throws Exception {
+ UploadInfo info = new UploadInfo();
+ info.setId(new UploadId("24249a5b-01a4-4bf8-b67a-364273bb5a2e"));
+ info.setOffset(100L);
+
+ ErrorResponse errorResponse = mock(ErrorResponse.class);
+ when(errorResponse.code()).thenReturn("NoSuchKey");
+ ErrorResponseException ex = new ErrorResponseException(errorResponse, null, null);
+ when(minioClient.getObject(any(GetObjectArgs.class))).thenThrow(ex);
+
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ storageService.copyUploadTo(info, baos);
+ }
+
+ @Test
+ public void testCleanupExpiredUploads() throws Exception {
+ UploadInfo expiredInfo = new UploadInfo();
+ UploadId expiredId = new UploadId("expired-123");
+ expiredInfo.setId(expiredId);
+ expiredInfo.setExpirationTimestamp(System.currentTimeMillis() - 10000L);
+
+ String json = UploadInfoJsonSerializer.serialize(expiredInfo);
+
+ Item item = mock(Item.class);
+ when(item.objectName()).thenReturn("tus-uploads/expired-123.info");
+ Result
- result = new Result<>(item);
+ when(minioClient.listObjects(any(ListObjectsArgs.class)))
+ .thenReturn(java.util.Collections.singletonList(result));
+
+ when(minioClient.getObject(any(GetObjectArgs.class)))
+ .thenAnswer(invocation -> mockGetObjectResponse(json.getBytes()));
+
+ UploadLockingService mockLocking = mock(UploadLockingService.class);
+ when(mockLocking.isLocked(expiredId)).thenReturn(false);
+
+ storageService.cleanupExpiredUploads(mockLocking);
+ }
+
+ @Test(expected = IOException.class)
+ public void testCleanupExpiredUploadsThrowsIOExceptionOnMinioFailure() throws Exception {
+ when(minioClient.listObjects(any(ListObjectsArgs.class)))
+ .thenThrow(new RuntimeException("MinIO failure"));
+
+ storageService.cleanupExpiredUploads(null);
+ }
+
+ @Test
+ public void testFinalizeCompletedUploadWithMultipleParts() throws Exception {
+ UploadInfo info = new UploadInfo();
+ UploadId id = new UploadId("multi-part-123");
+ info.setId(id);
+ info.setLength(100L);
+ info.setOffset(0L);
+
+ String json = UploadInfoJsonSerializer.serialize(info);
+
+ Item item1 = mock(Item.class);
+ when(item1.objectName()).thenReturn("tus-uploads/multi-part-123.part.00001");
+ Item item2 = mock(Item.class);
+ when(item2.objectName()).thenReturn("tus-uploads/multi-part-123.part.00002");
+
+ when(minioClient.listObjects(any(ListObjectsArgs.class)))
+ .thenReturn(Arrays.asList(new Result<>(item1), new Result<>(item2)));
+
+ when(minioClient.getObject(any(GetObjectArgs.class)))
+ .thenAnswer(invocation -> mockGetObjectResponse(json.getBytes()));
+
+ storageService.append(info, new ByteArrayInputStream(new byte[100]));
+ }
+
+ @Test
+ public void testFinalizeCompletedUploadWithLeftoverIncompletePart() throws Exception {
+ UploadInfo info = new UploadInfo();
+ UploadId id = new UploadId("leftover-part-123");
+ info.setId(id);
+ info.setLength(50L);
+ info.setOffset(0L);
+
+ String json = UploadInfoJsonSerializer.serialize(info);
+
+ StatObjectResponse leftoverHead = mock(StatObjectResponse.class);
+ when(leftoverHead.size()).thenReturn(50L);
+
+ when(minioClient.statObject(any(StatObjectArgs.class)))
+ .thenAnswer(
+ invocation -> {
+ StatObjectArgs args = invocation.getArgument(0);
+ if (args.object().endsWith(".part")) {
+ return leftoverHead;
+ }
+ ErrorResponse err = mock(ErrorResponse.class);
+ when(err.code()).thenReturn("NoSuchKey");
+ throw new ErrorResponseException(err, null, null);
+ });
+
+ when(minioClient.getObject(any(GetObjectArgs.class)))
+ .thenAnswer(
+ invocation -> {
+ GetObjectArgs args = invocation.getArgument(0);
+ if (args.object().endsWith(".info")) {
+ return mockGetObjectResponse(json.getBytes());
+ }
+ return mockGetObjectResponse(new byte[50]);
+ });
+
+ storageService.append(info, new ByteArrayInputStream(new byte[50]));
+ }
+
+ @Test
+ public void testFinalizeCompletedUploadZeroLength() throws Exception {
+ UploadInfo info = new UploadInfo();
+ UploadId id = new UploadId("zero-len-123");
+ info.setId(id);
+ info.setLength(0L);
+ info.setOffset(0L);
+
+ String json = UploadInfoJsonSerializer.serialize(info);
+ when(minioClient.getObject(any(GetObjectArgs.class)))
+ .thenAnswer(invocation -> mockGetObjectResponse(json.getBytes()));
+
+ storageService.append(info, new ByteArrayInputStream(new byte[0]));
+ }
+
+ @Test
+ public void testTruncateFromCompletedObject() throws Exception {
+ UploadInfo info = new UploadInfo();
+ UploadId id = new UploadId("trunc-completed-123");
+ info.setId(id);
+ info.setLength(100L);
+ info.setOffset(100L);
+
+ StatObjectResponse mockHead = mock(StatObjectResponse.class);
+ when(mockHead.size()).thenReturn(100L);
+ when(minioClient.statObject(any(StatObjectArgs.class))).thenReturn(mockHead);
+
+ when(minioClient.getObject(any(GetObjectArgs.class)))
+ .thenAnswer(invocation -> mockGetObjectResponse(new byte[100]));
+
+ storageService.removeLastNumberOfBytes(info, 30L);
+ assertEquals(Long.valueOf(70L), info.getOffset());
+ }
+
+ @Test
+ public void testTruncateFromIncompletePartByteCountGreaterThanPartSize() throws Exception {
+ UploadInfo info = new UploadInfo();
+ UploadId id = new UploadId("trunc-inc-123");
+ info.setId(id);
+ info.setOffset(50L);
+
+ ErrorResponse noSuchKeyErr = mock(ErrorResponse.class);
+ when(noSuchKeyErr.code()).thenReturn("NoSuchKey");
+ ErrorResponseException noSuchKeyEx = new ErrorResponseException(noSuchKeyErr, null, null);
+
+ StatObjectResponse partHead = mock(StatObjectResponse.class);
+ when(partHead.size()).thenReturn(50L);
+
+ when(minioClient.statObject(any(StatObjectArgs.class)))
+ .thenAnswer(
+ invocation -> {
+ StatObjectArgs args = invocation.getArgument(0);
+ if (args.object().endsWith(".part")) {
+ return partHead;
+ }
+ throw noSuchKeyEx;
+ });
+
+ storageService.removeLastNumberOfBytes(info, 100L);
+ assertEquals(Long.valueOf(0L), info.getOffset());
+ }
+
+ @Test
+ public void testTruncateFromIncompletePartPartialBytes() throws Exception {
+ UploadInfo info = new UploadInfo();
+ UploadId id = new UploadId("trunc-part-123");
+ info.setId(id);
+ info.setOffset(100L);
+
+ ErrorResponse noSuchKeyErr = mock(ErrorResponse.class);
+ when(noSuchKeyErr.code()).thenReturn("NoSuchKey");
+ ErrorResponseException noSuchKeyEx = new ErrorResponseException(noSuchKeyErr, null, null);
+
+ StatObjectResponse partHead = mock(StatObjectResponse.class);
+ when(partHead.size()).thenReturn(100L);
+
+ when(minioClient.statObject(any(StatObjectArgs.class)))
+ .thenAnswer(
+ invocation -> {
+ StatObjectArgs args = invocation.getArgument(0);
+ if (args.object().endsWith(".part")) {
+ return partHead;
+ }
+ throw noSuchKeyEx;
+ });
+
+ when(minioClient.getObject(any(GetObjectArgs.class)))
+ .thenAnswer(invocation -> mockGetObjectResponse(new byte[100]));
+
+ storageService.removeLastNumberOfBytes(info, 30L);
+ assertEquals(Long.valueOf(70L), info.getOffset());
+ }
+
+ @Test
+ public void testTerminateUploadWithChecksumAndParts() throws Exception {
+ UploadInfo info = new UploadInfo();
+ UploadId id = new UploadId("term-123");
+ info.setId(id);
+ info.setChecksum("hash123");
+ info.setChecksumAlgorithm(ChecksumAlgorithm.SHA256);
+
+ Item item1 = mock(Item.class);
+ when(item1.objectName()).thenReturn("tus-uploads/term-123.part.00001");
+ when(minioClient.listObjects(any(ListObjectsArgs.class)))
+ .thenReturn(java.util.Collections.singletonList(new Result<>(item1)));
+
+ storageService.terminateUpload(info);
+ }
+
+ @Test
+ public void testFetchS3ByteStreamIncompletePartFallback() throws Exception {
+ UploadInfo info = new UploadInfo();
+ UploadId id = new UploadId("fallback-123");
+ info.setId(id);
+ info.setOffset(50L);
+
+ ErrorResponse noSuchKeyErr = mock(ErrorResponse.class);
+ when(noSuchKeyErr.code()).thenReturn("NoSuchKey");
+ ErrorResponseException noSuchKeyEx = new ErrorResponseException(noSuchKeyErr, null, null);
+
+ when(minioClient.getObject(any(GetObjectArgs.class)))
+ .thenAnswer(
+ invocation -> {
+ GetObjectArgs args = invocation.getArgument(0);
+ if (args.object().endsWith(".info")) {
+ return mockGetObjectResponse(UploadInfoJsonSerializer.serialize(info).getBytes());
+ }
+ if (args.object().endsWith(".part")) {
+ return mockGetObjectResponse("part-data".getBytes());
+ }
+ throw noSuchKeyEx;
+ });
+
+ InputStream stream = storageService.getUploadedBytes(id);
+ assertNotNull(stream);
+ }
+
+ @Test
+ public void testFetchS3ByteStreamZeroOffsetFallback() throws Exception {
+ UploadInfo info = new UploadInfo();
+ UploadId id = new UploadId("zero-offset-123");
+ info.setId(id);
+ info.setOffset(0L);
+
+ ErrorResponse noSuchKeyErr = mock(ErrorResponse.class);
+ when(noSuchKeyErr.code()).thenReturn("NoSuchKey");
+ ErrorResponseException noSuchKeyEx = new ErrorResponseException(noSuchKeyErr, null, null);
+
+ when(minioClient.getObject(any(GetObjectArgs.class)))
+ .thenAnswer(
+ invocation -> {
+ GetObjectArgs args = invocation.getArgument(0);
+ if (args.object().endsWith(".info")) {
+ return mockGetObjectResponse(UploadInfoJsonSerializer.serialize(info).getBytes());
+ }
+ throw noSuchKeyEx;
+ });
+
+ InputStream stream = storageService.getUploadedBytes(id);
+ assertNotNull(stream);
+ assertEquals(0, stream.available());
+ }
+
+ @Test
+ public void testPutChecksumIndexAndObjectExistsExceptions() throws Exception {
+ storageService.setUploadDeduplicationEnabled(true);
+
+ UploadInfo info = new UploadInfo();
+ info.setId(new UploadId("chk-123"));
+ info.setLength(100L);
+ info.setOffset(100L);
+ info.setChecksum("hashabc");
+ info.setChecksumAlgorithm(ChecksumAlgorithm.SHA256);
+
+ when(minioClient.putObject(any(PutObjectArgs.class)))
+ .thenAnswer(
+ invocation -> {
+ PutObjectArgs args = invocation.getArgument(0);
+ if (args.object().contains("checksums")) {
+ throw new RuntimeException("Checksum put failure");
+ }
+ return null;
+ });
+
+ storageService.update(info);
+ }
+
+ @Test(expected = IOException.class)
+ public void testUpdateThrowsIOExceptionOnMinioFailure() throws Exception {
+ UploadInfo info = new UploadInfo();
+ info.setId(new UploadId("upd-err-123"));
+ when(minioClient.putObject(any(PutObjectArgs.class)))
+ .thenThrow(new RuntimeException("PutObject failure"));
+ storageService.update(info);
+ }
+
+ @Test
+ public void testCreateWhenUpdateThrowsUploadNotFoundException() throws Exception {
+ S3StorageService spyService = org.mockito.Mockito.spy(storageService);
+ UploadInfo info = new UploadInfo();
+ org.mockito.Mockito.doThrow(
+ new me.desair.tus.server.exception.UploadNotFoundException("Not found"))
+ .when(spyService)
+ .update(any(UploadInfo.class));
+ UploadInfo created = spyService.create(info, "owner");
+ assertNotNull(created);
+ }
+
+ @Test(expected = IOException.class)
+ public void testGetUploadInfoByChecksumWithNonNoSuchKeyError() throws Exception {
+ storageService.setUploadDeduplicationEnabled(true);
+ ErrorResponse err = mock(ErrorResponse.class);
+ when(err.code()).thenReturn("AccessDenied");
+ ErrorResponseException ex = new ErrorResponseException(err, null, null);
+
+ when(minioClient.getObject(any(GetObjectArgs.class))).thenThrow(ex);
+
+ storageService.getUploadInfoByChecksum("hash", ChecksumAlgorithm.SHA1);
+ }
+
+ @Test(expected = IOException.class)
+ public void testGetUploadInfoByChecksumWithGenericException() throws Exception {
+ storageService.setUploadDeduplicationEnabled(true);
+ when(minioClient.getObject(any(GetObjectArgs.class)))
+ .thenThrow(new RuntimeException("MinIO error"));
+
+ storageService.getUploadInfoByChecksum("hash", ChecksumAlgorithm.SHA1);
+ }
+
+ @Test
+ public void testPrepareStreamWithExistingIncompletePartGenericException() throws Exception {
+ UploadInfo info = new UploadInfo();
+ UploadId id = new UploadId("prep-err-123");
+ info.setId(id);
+ info.setLength(100L);
+ info.setOffset(0L);
+
+ when(minioClient.getObject(any(GetObjectArgs.class)))
+ .thenAnswer(
+ inv -> {
+ GetObjectArgs args = inv.getArgument(0);
+ if (args.object().endsWith(".info")) {
+ return mockGetObjectResponse(UploadInfoJsonSerializer.serialize(info).getBytes());
+ }
+ throw new RuntimeException("GetObject error");
+ });
+
+ when(minioClient.statObject(any(StatObjectArgs.class)))
+ .thenThrow(new RuntimeException("Stat error"));
+
+ ByteArrayInputStream bais = new ByteArrayInputStream(new byte[10]);
+ storageService.append(info, bais);
+ }
+
+ @Test(expected = IOException.class)
+ public void testUploadChunkToS3ThrowsIOException() throws Exception {
+ UploadInfo info = new UploadInfo();
+ info.setId(new UploadId("chunk-err-123"));
+ info.setLength(100L);
+ info.setOffset(0L);
+
+ when(minioClient.getObject(any(GetObjectArgs.class)))
+ .thenAnswer(
+ inv -> {
+ GetObjectArgs args = inv.getArgument(0);
+ if (args.object().endsWith(".info")) {
+ return mockGetObjectResponse(UploadInfoJsonSerializer.serialize(info).getBytes());
+ }
+ throw new RuntimeException("GetObject error");
+ });
+
+ when(minioClient.putObject(any(PutObjectArgs.class)))
+ .thenAnswer(
+ inv -> {
+ PutObjectArgs args = inv.getArgument(0);
+ if (args.object().endsWith(".info")) {
+ return null;
+ }
+ throw new RuntimeException("Chunk put failure");
+ });
+
+ storageService.append(info, new ByteArrayInputStream(new byte[10]));
+ }
+
+ @Test(expected = IOException.class)
+ public void testFinalizeCompletedUploadSinglePartComposeException() throws Exception {
+ UploadInfo info = new UploadInfo();
+ info.setId(new UploadId("single-compose-err"));
+ info.setLength(10L);
+ info.setOffset(0L);
+
+ ErrorResponse noSuchKeyErr = mock(ErrorResponse.class);
+ when(noSuchKeyErr.code()).thenReturn("NoSuchKey");
+ ErrorResponseException noSuchKeyEx = new ErrorResponseException(noSuchKeyErr, null, null);
+
+ when(minioClient.getObject(any(GetObjectArgs.class)))
+ .thenAnswer(
+ inv -> {
+ GetObjectArgs args = inv.getArgument(0);
+ if (args.object().endsWith(".info")) {
+ return mockGetObjectResponse(UploadInfoJsonSerializer.serialize(info).getBytes());
+ }
+ throw noSuchKeyEx;
+ });
+
+ StatObjectResponse statRes = mock(StatObjectResponse.class);
+ when(statRes.size()).thenReturn(10L);
+
+ when(minioClient.statObject(any(StatObjectArgs.class)))
+ .thenAnswer(
+ inv -> {
+ StatObjectArgs args = inv.getArgument(0);
+ if (args.object().endsWith(".part")) {
+ throw noSuchKeyEx;
+ }
+ return statRes;
+ });
+
+ Item item1 = mock(Item.class);
+ when(item1.objectName()).thenReturn("tus-uploads/single-compose-err.part.00001");
+ when(minioClient.listObjects(any(ListObjectsArgs.class)))
+ .thenReturn(Arrays.asList(new Result<>(item1)));
+
+ doThrow(new RuntimeException("Compose error"))
+ .when(minioClient)
+ .composeObject(any(io.minio.ComposeObjectArgs.class));
+
+ storageService.append(info, new ByteArrayInputStream(new byte[10]));
+ }
+
+ @Test(expected = IOException.class)
+ public void testFinalizeCompletedUploadMultipartComposeException() throws Exception {
+ UploadInfo info = new UploadInfo();
+ info.setId(new UploadId("multi-compose-err"));
+ info.setLength(20L);
+ info.setOffset(0L);
+
+ ErrorResponse noSuchKeyErr = mock(ErrorResponse.class);
+ when(noSuchKeyErr.code()).thenReturn("NoSuchKey");
+ ErrorResponseException noSuchKeyEx = new ErrorResponseException(noSuchKeyErr, null, null);
+
+ when(minioClient.getObject(any(GetObjectArgs.class)))
+ .thenAnswer(
+ inv -> {
+ GetObjectArgs args = inv.getArgument(0);
+ if (args.object().endsWith(".info")) {
+ return mockGetObjectResponse(UploadInfoJsonSerializer.serialize(info).getBytes());
+ }
+ throw noSuchKeyEx;
+ });
+
+ StatObjectResponse statRes = mock(StatObjectResponse.class);
+ when(statRes.size()).thenReturn(10L);
+
+ when(minioClient.statObject(any(StatObjectArgs.class)))
+ .thenAnswer(
+ inv -> {
+ StatObjectArgs args = inv.getArgument(0);
+ if (args.object().endsWith(".part")) {
+ throw noSuchKeyEx;
+ }
+ return statRes;
+ });
+
+ Item item1 = mock(Item.class);
+ when(item1.objectName()).thenReturn("tus-uploads/multi-compose-err.part.00001");
+ Item item2 = mock(Item.class);
+ when(item2.objectName()).thenReturn("tus-uploads/multi-compose-err.part.00002");
+
+ when(minioClient.listObjects(any(ListObjectsArgs.class)))
+ .thenReturn(Arrays.asList(new Result<>(item1), new Result<>(item2)));
+
+ doThrow(new RuntimeException("Multipart compose error"))
+ .when(minioClient)
+ .composeObject(any(ComposeObjectArgs.class));
+
+ storageService.append(info, new ByteArrayInputStream(new byte[20]));
+ }
+
+ @Test(expected = IOException.class)
+ public void testFinalizeCompletedUploadZeroBytePutException() throws Exception {
+ UploadInfo info = new UploadInfo();
+ info.setId(new UploadId("zero-byte-err"));
+ info.setLength(0L);
+ info.setOffset(0L);
+
+ when(minioClient.getObject(any(GetObjectArgs.class)))
+ .thenAnswer(
+ inv -> {
+ GetObjectArgs args = inv.getArgument(0);
+ if (args.object().endsWith(".info")) {
+ return mockGetObjectResponse(UploadInfoJsonSerializer.serialize(info).getBytes());
+ }
+ throw new RuntimeException("GetObject error");
+ });
+
+ when(minioClient.putObject(any(PutObjectArgs.class)))
+ .thenAnswer(
+ inv -> {
+ PutObjectArgs args = inv.getArgument(0);
+ if (args.object().endsWith(".info")) {
+ return null;
+ }
+ throw new RuntimeException("Zero byte put error");
+ });
+
+ storageService.append(info, new ByteArrayInputStream(new byte[0]));
+ }
+
+ @Test(expected = me.desair.tus.server.exception.UploadNotFoundException.class)
+ public void testFetchS3ByteStreamGenericExceptionOnObjectKey() throws Exception {
+ UploadInfo info = new UploadInfo();
+ UploadId id = new UploadId("fetch-err-123");
+ info.setId(id);
+ info.setOffset(10L);
+
+ when(minioClient.getObject(any(GetObjectArgs.class)))
+ .thenAnswer(
+ inv -> {
+ GetObjectArgs args = inv.getArgument(0);
+ if (args.object().endsWith(".info")) {
+ return mockGetObjectResponse(UploadInfoJsonSerializer.serialize(info).getBytes());
+ }
+ throw new RuntimeException("GetObject failure");
+ });
+
+ storageService.getUploadedBytes(id);
+ }
+
+ @Test(expected = IOException.class)
+ public void testTruncateFromCompletedObjectThrowsIOException() throws Exception {
+ UploadInfo info = new UploadInfo();
+ info.setId(new UploadId("trunc-err-123"));
+ info.setLength(20L);
+ info.setOffset(20L);
+
+ when(minioClient.getObject(any(GetObjectArgs.class)))
+ .thenAnswer(
+ inv -> {
+ GetObjectArgs args = inv.getArgument(0);
+ if (args.object().endsWith(".info")) {
+ return mockGetObjectResponse(UploadInfoJsonSerializer.serialize(info).getBytes());
+ }
+ throw new RuntimeException("GetObject completed object failure");
+ });
+
+ storageService.removeLastNumberOfBytes(info, 5L);
+ }
+
+ @Test
+ public void testTruncateFromIncompletePartExceptionHandling() throws Exception {
+ UploadInfo info = new UploadInfo();
+ info.setId(new UploadId("trunc-part-err"));
+ info.setLength(20L);
+ info.setOffset(10L);
+
+ when(minioClient.statObject(any(StatObjectArgs.class)))
+ .thenThrow(new RuntimeException("Stat object error"));
+
+ storageService.removeLastNumberOfBytes(info, 5L);
+ }
+
+ @Test
+ public void testCalculateCurrentOffsetIncompletePartHeadException() throws Exception {
+ UploadInfo info = new UploadInfo();
+ UploadId id = new UploadId("offset-head-err");
+ info.setId(id);
+
+ when(minioClient.getObject(any(GetObjectArgs.class)))
+ .thenAnswer(
+ inv -> {
+ GetObjectArgs args = inv.getArgument(0);
+ if (args.object().endsWith(".info")) {
+ return mockGetObjectResponse(UploadInfoJsonSerializer.serialize(info).getBytes());
+ }
+ throw new RuntimeException("GetObject error");
+ });
+
+ when(minioClient.statObject(any(StatObjectArgs.class)))
+ .thenThrow(new RuntimeException("Head error"));
+
+ UploadInfo fetched = storageService.getUploadInfo(id);
+ }
+
+ @Test
+ public void testFetchExistingPartKeysExceptionIgnored() throws Exception {
+ UploadInfo info = new UploadInfo();
+ info.setId(new UploadId("list-err-123"));
+ info.setLength(10L);
+ info.setOffset(0L);
+
+ when(minioClient.getObject(any(GetObjectArgs.class)))
+ .thenAnswer(
+ inv -> {
+ GetObjectArgs args = inv.getArgument(0);
+ if (args.object().endsWith(".info")) {
+ return mockGetObjectResponse(UploadInfoJsonSerializer.serialize(info).getBytes());
+ }
+ throw new RuntimeException("GetObject error");
+ });
+
+ when(minioClient.listObjects(any(ListObjectsArgs.class)))
+ .thenThrow(new RuntimeException("List objects error"));
+
+ storageService.append(info, new ByteArrayInputStream(new byte[10]));
+ }
+
+ @Test
+ public void testCalcOptimalPartSizeForVeryLargeUpload() throws Exception {
+ UploadInfo info = new UploadInfo();
+ info.setId(new UploadId("large-upload-123"));
+ info.setLength(50000000000L);
+ info.setOffset(0L);
+
+ UploadInfo created = storageService.create(info, "owner");
+ assertNotNull(created);
+ }
+
+ @Test
+ public void testDeleteObjectQuietlyNullAndException() throws Exception {
+ org.mockito.Mockito.doThrow(new RuntimeException("Remove object error"))
+ .when(minioClient)
+ .removeObject(any(io.minio.RemoveObjectArgs.class));
+
+ storageService.terminateUpload(null);
+
+ UploadInfo info = new UploadInfo();
+ info.setId(new UploadId("del-err-123"));
+ storageService.terminateUpload(info);
+ }
+
+ @Test
+ public void testSanitizePrefixNullOrEmptyInS3StorageService() throws Exception {
+ java.nio.file.Path tmpDir = java.nio.file.Paths.get(System.getProperty("java.io.tmpdir"));
+
+ S3StorageService s1 = new S3StorageService(minioClient, "bucket", "", "", "", "", tmpDir);
+ assertNotNull(s1);
+
+ S3StorageService s2 =
+ new S3StorageService(minioClient, "bucket", null, null, null, null, tmpDir);
+ assertNotNull(s2);
+ }
+
+ private GetObjectResponse mockGetObjectResponse(byte[] bytes) {
+ return new GetObjectResponse(
+ null, "test-bucket", "us-east-1", "object-key", new ByteArrayInputStream(bytes));
+ }
+}
diff --git a/src/test/java/me/desair/tus/server/upload/s3/S3UploadLockTest.java b/src/test/java/me/desair/tus/server/upload/s3/S3UploadLockTest.java
new file mode 100644
index 00000000..cbff78ae
--- /dev/null
+++ b/src/test/java/me/desair/tus/server/upload/s3/S3UploadLockTest.java
@@ -0,0 +1,128 @@
+package me.desair.tus.server.upload.s3;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+
+import io.minio.MinioClient;
+import io.minio.PutObjectArgs;
+import io.minio.RemoveObjectArgs;
+import java.io.InputStream;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.Mockito;
+
+public class S3UploadLockTest {
+
+ private MinioClient minioClient;
+ private ConcurrentMap inputStreamMap;
+
+ @Before
+ public void setUp() {
+ minioClient = mock(MinioClient.class);
+ inputStreamMap = new ConcurrentHashMap<>();
+ }
+
+ @Test
+ public void testLockGettersReleaseAndRenewLease() throws Exception {
+ InputStream mockStream = mock(InputStream.class);
+ inputStreamMap.put("/files/upload-1", mockStream);
+
+ S3UploadLock lock =
+ new S3UploadLock(
+ minioClient,
+ "test-bucket",
+ "tus-locks/upload-1.lock",
+ "tus-locks/upload-1.stop",
+ "holder-123",
+ 60000L,
+ "/files/upload-1",
+ inputStreamMap);
+
+ assertEquals("holder-123", lock.getHolderId());
+ assertEquals("/files/upload-1", lock.getUploadUri());
+
+ // Explicitly call renewLease() to verify lease renewal
+ lock.renewLease();
+
+ lock.release();
+ assertNotNull(lock);
+ }
+
+ @Test
+ public void testRenewLeaseExceptionHandling() throws Exception {
+ Mockito.doThrow(new RuntimeException("PutObject failed"))
+ .when(minioClient)
+ .putObject(any(PutObjectArgs.class));
+
+ S3UploadLock lock =
+ new S3UploadLock(
+ minioClient,
+ "test-bucket",
+ "tus-locks/upload-1.lock",
+ "tus-locks/upload-1.stop",
+ "holder-123",
+ 60000L,
+ "/files/upload-1",
+ inputStreamMap);
+
+ lock.renewLease();
+ }
+
+ @Test
+ public void testLockDeleteQuietlyWithNullKeysAndExceptionHandling() throws Exception {
+ Mockito.doThrow(new RuntimeException("Remove failed"))
+ .when(minioClient)
+ .removeObject(any(RemoveObjectArgs.class));
+
+ S3UploadLock lockWithNullKeys =
+ new S3UploadLock(
+ minioClient,
+ "test-bucket",
+ null,
+ null,
+ "holder-123",
+ 60000L,
+ "/files/upload-1",
+ inputStreamMap);
+
+ lockWithNullKeys.close();
+
+ S3UploadLock lockWithKeys =
+ new S3UploadLock(
+ minioClient,
+ "test-bucket",
+ "tus-locks/upload-1.lock",
+ "tus-locks/upload-1.stop",
+ "holder-123",
+ 60000L,
+ "/files/upload-1",
+ inputStreamMap);
+
+ lockWithKeys.close();
+ }
+
+ @Test
+ public void testCloseHeartbeatExecutorShutdownException() throws Exception {
+ java.util.concurrent.ScheduledExecutorService mockExecutor =
+ mock(java.util.concurrent.ScheduledExecutorService.class);
+ Mockito.doThrow(new RuntimeException("Shutdown error")).when(mockExecutor).shutdownNow();
+
+ S3UploadLock lock =
+ new S3UploadLock(
+ minioClient,
+ "test-bucket",
+ "tus-locks/upload-1.lock",
+ "tus-locks/upload-1.stop",
+ "holder-123",
+ 60000L,
+ "/files/upload-1",
+ inputStreamMap,
+ mockExecutor);
+
+ lock.close();
+ }
+}
diff --git a/src/test/java/me/desair/tus/server/upload/s3/S3UtilsTest.java b/src/test/java/me/desair/tus/server/upload/s3/S3UtilsTest.java
new file mode 100644
index 00000000..19262143
--- /dev/null
+++ b/src/test/java/me/desair/tus/server/upload/s3/S3UtilsTest.java
@@ -0,0 +1,41 @@
+package me.desair.tus.server.upload.s3;
+
+import static org.junit.Assert.assertEquals;
+
+import io.minio.errors.ErrorResponseException;
+import io.minio.messages.ErrorResponse;
+import org.junit.Test;
+
+public class S3UtilsTest {
+
+ @Test
+ public void testParseErrorResponseNull() {
+ assertEquals(S3ErrorType.UNKNOWN, S3Utils.parseErrorResponse(null));
+ }
+
+ @Test
+ public void testParseErrorResponseCodes() throws Exception {
+ assertEquals(
+ S3ErrorType.NO_SUCH_KEY, S3Utils.parseErrorResponse(createExceptionWithCode("NoSuchKey")));
+ assertEquals(
+ S3ErrorType.NO_SUCH_KEY,
+ S3Utils.parseErrorResponse(createExceptionWithCode("NoSuchBucket")));
+ assertEquals(
+ S3ErrorType.PRECONDITION_FAILED,
+ S3Utils.parseErrorResponse(createExceptionWithCode("PreconditionFailed")));
+ assertEquals(
+ S3ErrorType.CONFLICT,
+ S3Utils.parseErrorResponse(createExceptionWithCode("ObjectAlreadyExists")));
+ assertEquals(
+ S3ErrorType.ACCESS_DENIED,
+ S3Utils.parseErrorResponse(createExceptionWithCode("AccessDenied")));
+ assertEquals(
+ S3ErrorType.UNKNOWN, S3Utils.parseErrorResponse(createExceptionWithCode("InternalError")));
+ }
+
+ private ErrorResponseException createExceptionWithCode(String code) {
+ ErrorResponse errorResponse = org.mockito.Mockito.mock(ErrorResponse.class);
+ org.mockito.Mockito.when(errorResponse.code()).thenReturn(code);
+ return new ErrorResponseException(errorResponse, null, null);
+ }
+}
diff --git a/src/test/java/me/desair/tus/server/util/UploadInfoJsonSerializerTest.java b/src/test/java/me/desair/tus/server/util/UploadInfoJsonSerializerTest.java
new file mode 100644
index 00000000..9a8a5d33
--- /dev/null
+++ b/src/test/java/me/desair/tus/server/util/UploadInfoJsonSerializerTest.java
@@ -0,0 +1,70 @@
+package me.desair.tus.server.util;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import me.desair.tus.server.upload.UploadId;
+import me.desair.tus.server.upload.UploadInfo;
+import org.junit.Test;
+
+public class UploadInfoJsonSerializerTest {
+
+ @Test
+ public void testSerializeAndDeserializeUploadInfo() throws Exception {
+ UploadInfo info = new UploadInfo();
+ info.setId(new UploadId("24249a5b-01a4-4bf8-b67a-364273bb5a2e"));
+ info.setLength(1024L);
+ info.setOffset(512L);
+ info.setOwnerKey("owner-1");
+ info.setStorageUploadId("custom-storage-id");
+
+ String json = UploadInfoJsonSerializer.serialize(info);
+ assertNotNull(json);
+
+ UploadInfo deserialized = UploadInfoJsonSerializer.deserialize(json);
+ assertNotNull(deserialized);
+ assertEquals("24249a5b-01a4-4bf8-b67a-364273bb5a2e", deserialized.getId().toString());
+ assertEquals(Long.valueOf(1024L), deserialized.getLength());
+ assertEquals(Long.valueOf(512L), deserialized.getOffset());
+ assertEquals("owner-1", deserialized.getOwnerKey());
+ assertEquals("custom-storage-id", deserialized.getStorageUploadId());
+
+ // Test InputStream overload
+ UploadInfo fromStream =
+ UploadInfoJsonSerializer.deserialize(
+ new ByteArrayInputStream(json.getBytes(StandardCharsets.UTF_8)));
+ assertNotNull(fromStream);
+ assertEquals("24249a5b-01a4-4bf8-b67a-364273bb5a2e", fromStream.getId().toString());
+
+ // Test OutputStream overload
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ UploadInfoJsonSerializer.serializeToStream(info, baos);
+ UploadInfo fromStream2 =
+ UploadInfoJsonSerializer.deserialize(new ByteArrayInputStream(baos.toByteArray()));
+ assertNotNull(fromStream2);
+ assertEquals("24249a5b-01a4-4bf8-b67a-364273bb5a2e", fromStream2.getId().toString());
+ }
+
+ @Test
+ public void testNullAndEmptyHandling() throws Exception {
+ assertNull(UploadInfoJsonSerializer.serialize(null));
+ assertNull(UploadInfoJsonSerializer.deserialize((String) null));
+ assertNull(UploadInfoJsonSerializer.deserialize((InputStream) null));
+ assertNull(UploadInfoJsonSerializer.deserialize(""));
+
+ UploadInfo emptyIdInfo = UploadInfoJsonSerializer.deserialize("{\"id\":\"\"}");
+ assertNotNull(emptyIdInfo);
+ assertNull(emptyIdInfo.getId());
+
+ try {
+ UploadInfoJsonSerializer.deserialize("invalid-json");
+ } catch (Exception expected) {
+ // expected
+ }
+ }
+}