diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fbba1bd..bf17d116 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,12 +15,14 @@ All notable changes to this project will be documented in this file. - **RFC 7807 Problem Details JSON**: Added support for standard `application/problem+json` error responses (`mismatching-upload-offset`, `completed-upload`, `inconsistent-upload-length`). - **Dedicated Compliance Test Suites**: Added comprehensive, spec-quoted end-to-end tests using a dedicated Python script `scripts/rufh_conformity_test.py` with documentation on how to run the tests in `docs/CONFORMITY_TESTING.md`. - **User Migration & Interim Responses Documentation**: Added `docs/MIGRATION.md` and `docs/INTERIM_RESPONSES.md` detailing migration strategies, HTTP 104 status frames under IETF RUFH, Tomcat/Servlet container limitations, cached reflection optimizations, and Spring Boot Tomcat Valve integration. +- **Server-Side Upload Completion Listeners (`UploadCompletionListener`)**: Added a functional interface callback mechanism allowing developers to register post-upload listeners via `withUploadCompletionListener(UploadCompletionListener)` or `addUploadCompletionListener(UploadCompletionListener)`. Listeners receive the completed `UploadInfo` and `TusFileUploadService` instance after lock release, allowing immediate byte streaming and deletion without contention. Added helper overloads `TusFileUploadService.getUploadedBytes(UploadInfo)` and `TusFileUploadService.deleteUpload(UploadInfo)`. - **JSON Serialization**: Support storing `UploadInfo` objects as JSON files in the storage backend using `TusFileUploadService.withJsonSerialization(true)`. ### Changed - **Default Disk-Based Locking**: `TusFileUploadService.withStoragePath(String)` now defaults to `LeaseFileLockingService` instead of `DiskLockingService` for out-of-the-box Kubernetes, container, and shared network storage compatibility. See `docs/DISK_BASED_LOCKING.md` for legacy opt-out instructions. - **Calibrated Retry Budget**: Extended `TusFileUploadService` lock acquisition retry budget to 8.0 seconds (40 retries x 200ms) to ensure reliable contention resolution over network storage. - **Absolute Base URL & Location Header Support**: Extended `withUploadUri(String)` to accept absolute base URLs (e.g. `https://upload.example.com/files`), returning full URLs in `Location` response headers for upload creation across both Tus 1.0.0 and RUFH protocols while preserving backward compatibility for relative paths. +- **`process()` Return Value (`UploadInfo`)**: `TusFileUploadService.process(...)` now returns the created or updated `UploadInfo` instance (or `null` on errors or `OPTIONS` preflight requests), enabling applications to track and store upload IDs directly into user sessions or database repositories. ### Fixed - **Clear Content-Length on Error Responses**: Cleared `Content-Length` response header prior to invoking `HttpServletResponse.sendError(...)` during exception handling, resolving buffer conflicts and exceptions in Undertow and other servlet containers ([#40](https://github.com/tomdesair/tus-java-server/issues/40)). diff --git a/README.md b/README.md index c3583a55..22cbc064 100644 --- a/README.md +++ b/README.md @@ -200,6 +200,7 @@ After creating the object, you can configure it using the following methods: | `addTusExtension(TusExtension)` | All standard enabled | Adds a custom extension (e.g. application authorization checks). | | `disableTusExtension(String)` | None | Disables a built-in extension (`creation`, `checksum`, `expiration`, `concatenation`, `termination`, `download`, `cors`). | | `withUploadIdFactory(UploadIdFactory)` | `UuidUploadIdFactory` | Custom ID generator for upload resources (e.g., `UuidUploadIdFactory` or `TimeBasedUploadIdFactory`). | +| `withUploadCompletionListener(UploadCompletionListener)` | None | Registers a callback invoked immediately when an upload finishes transferring all bytes and is completed. | | `withJsonSerialization()` | Java serialization | Enables JSON serialization for upload metadata (`UploadInfo`), requiring Jackson databind on classpath. | | `withUploadStorageService(UploadStorageService)` | `DiskStorageService` | Configures custom or cloud storage backend (`DiskStorageService`, `S3StorageService`, `AzureBlobStorageService`). | | `withUploadLockingService(UploadLockingService)` | `LeaseFileLockingService` | Configures custom or cloud locking backend (`LeaseFileLockingService`, `S3LockingService`, `AzureBlobLockingService`). | @@ -209,6 +210,8 @@ The library provides filesystem-based storage (`DiskStorageService` / `LeaseFile ### 2. Receiving a Resumable Upload To process an upload request you have to pass the current `jakarta.servlet.http.HttpServletRequest` and `jakarta.servlet.http.HttpServletResponse` objects to the `me.desair.tus.server.TusFileUploadService.process()` method. Typical places were you can do this are inside Servlets, Filters or REST API Controllers. +The `process()` method returns the processed `UploadInfo` object (or `null` for `OPTIONS` preflight requests or when an error response is generated). + For example, in a Spring MVC REST Controller: ```java @@ -219,6 +222,9 @@ public class FileUploadController { @Autowired private TusFileUploadService tusFileUploadService; + @Autowired + private UserSessionRepository userSessionRepository; + @RequestMapping( value = {"/api/upload", "/api/upload/**"}, method = { @@ -230,17 +236,58 @@ public class FileUploadController { RequestMethod.OPTIONS, RequestMethod.GET }) - public void processUpload(HttpServletRequest request, HttpServletResponse response) + public void processUpload( + HttpServletRequest request, HttpServletResponse response, @RequestParam String userSessionId) throws IOException { - tusFileUploadService.process(request, response); + + UploadInfo info = tusFileUploadService.process(request, response, userSessionId); + + // Creation vs. Progress Ambiguity: Check if this was a new upload creation request + if (info != null && "POST".equalsIgnoreCase(request.getMethod())) { + userSessionRepository.recordUpload(userSessionId, info.getId()); + } } } ``` +> [!NOTE] +> **Creation vs. Progress Ambiguity**: The `process()` method returns an `UploadInfo` on both creation (`POST`) and progress updates (`PATCH` / `HEAD`). When associating an upload ID with a user session or database entity on creation, check `info != null && "POST".equalsIgnoreCase(request.getMethod())`. +> +> **Single-Request Upload Completion**: When clients upload the entire file in a single request using the `creation-with-upload` extension or RUFH single-request POST, the upload is already complete when `process()` returns, and any registered `UploadCompletionListener` will have already fired *before* `process()` returns to your controller. + Optionally you can also pass a `String ownerKey` parameter to `process()`. The `ownerKey` can be used to have a hard separation between uploads of different users, groups or tenants in a multi-tenant setup. Examples of `ownerKey` values are user ID's, group names, client ID's... ### 3. Handling Upload Completion & Retrieving Files -When an upload completes, the client receives the final `204 No Content` response from the Tus protocol endpoint (`/api/upload/...`). Because Tus is a decoupled file transport protocol, your frontend application typically notifies your backend domain API (e.g. `POST /api/documents`) that the upload is complete and passes along the `uploadUrl`. + +You can handle upload completion in two ways: +1. **Server-Side Callback Listener (`UploadCompletionListener`)**: Register a hook directly on `TusFileUploadService` that triggers automatically when the final chunk is uploaded. +2. **Domain API Endpoint**: Have your frontend Tus client notify your application backend domain endpoint (e.g. `POST /api/documents`) once upload completes. + +#### Option A: Server-Side `UploadCompletionListener` +Register one or more completion listeners on your `TusFileUploadService`. When an upload finishes transferring all bytes, the callback executes with the completed `UploadInfo` and the service instance. The upload lock is released *before* the listener is invoked, allowing you to safely stream or delete uploaded bytes immediately: + +```java +@Bean +public TusFileUploadService tusFileUploadService() { + return new TusFileUploadService() + .withStoragePath("/path/to/uploads") + .withUploadUri("/api/upload") + .withUploadCompletionListener((uploadInfo, service) -> { + // Filename needs to be set as metadata by the client + String fileName = uploadInfo.getMetadata().get("filename"); + try (InputStream is = service.getUploadedBytes(uploadInfo)) { + Files.copy(is, Paths.get("/var/data/documents", fileName)); + } catch (Exception e) { + log.error("Failed to process completed upload {}", uploadInfo.getId(), e); + } + // Optionally delete upload temporary files after processing + service.deleteUpload(uploadInfo); + }); +} +``` + +#### Option B: Client-Initiated Domain Notification +When an upload completes, the client receives the final `204 No Content` response from the Tus/RUFH protocol endpoint (`/api/upload/...`). Because Tus and RUFH are decoupled file transport protocols, your frontend application can notify your backend domain API (e.g. `POST /api/documents`) that the upload is complete and pass along the `uploadUrl`. > [!NOTE] > `POST /api/documents` represents your application's domain REST endpoint, not a protocol endpoint. The Tus server itself handles file transfer (`/api/upload`), while your application endpoint coordinates business logic, database persistence, and final file consumption. diff --git a/src/main/java/me/desair/tus/server/TusFileUploadService.java b/src/main/java/me/desair/tus/server/TusFileUploadService.java index c6299713..6801ec34 100644 --- a/src/main/java/me/desair/tus/server/TusFileUploadService.java +++ b/src/main/java/me/desair/tus/server/TusFileUploadService.java @@ -9,8 +9,10 @@ import java.util.EnumSet; import java.util.LinkedHashMap; import java.util.LinkedHashSet; +import java.util.List; import java.util.Objects; import java.util.Set; +import java.util.concurrent.CopyOnWriteArrayList; import me.desair.tus.server.checksum.ChecksumExtension; import me.desair.tus.server.concatenation.ConcatenationExtension; import me.desair.tus.server.core.CoreProtocol; @@ -24,6 +26,7 @@ import me.desair.tus.server.rufh.ResumableUploadsForHttpProtocol; import me.desair.tus.server.rufh.util.RufhInterimResponseUtil; import me.desair.tus.server.termination.TerminationExtension; +import me.desair.tus.server.upload.UploadId; import me.desair.tus.server.upload.UploadIdFactory; import me.desair.tus.server.upload.UploadInfo; import me.desair.tus.server.upload.UploadLock; @@ -37,6 +40,7 @@ import me.desair.tus.server.util.TusServletResponse; import me.desair.tus.server.util.Utils; import org.apache.commons.io.FileUtils; +import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.Strings; import org.apache.commons.lang3.Validate; import org.slf4j.Logger; @@ -63,6 +67,8 @@ public class TusFileUploadService implements Closeable { private boolean isChunkedTransferDecodingEnabled = false; private ProtocolVersion supportedProtocolVersion = ProtocolVersion.AUTO; private int maxLockRetries = DEFAULT_MAX_LOCK_RETRIES; + private final List uploadCompletionListeners = + new CopyOnWriteArrayList<>(); /** Constructor. */ public TusFileUploadService() { @@ -87,6 +93,29 @@ protected void initFeatures() { addTusExtension(new HttpDigestsExtension()); } + /** + * Register a callback listener that is invoked when an upload successfully reaches completion. + * + * @param listener The completion listener to register + * @return The current service + */ + public TusFileUploadService withUploadCompletionListener(UploadCompletionListener listener) { + return addUploadCompletionListener(listener); + } + + /** + * Add a callback listener that is invoked when an upload successfully reaches completion. + * + * @param listener The completion listener to add + * @return The current service + */ + public TusFileUploadService addUploadCompletionListener(UploadCompletionListener listener) { + if (listener != null) { + this.uploadCompletionListeners.add(listener); + } + return this; + } + /** * Configure the supported protocol version(s) for this service. * @@ -464,11 +493,12 @@ public Set getEnabledFeatures() { * * @param servletRequest The {@link HttpServletRequest} of the request * @param servletResponse The {@link HttpServletResponse} of the request + * @return The processed {@link UploadInfo} or null if no upload was involved or an error occurred * @throws IOException When saving bytes or information of this requests fails */ - public void process(HttpServletRequest servletRequest, HttpServletResponse servletResponse) + public UploadInfo process(HttpServletRequest servletRequest, HttpServletResponse servletResponse) throws IOException { - process(servletRequest, servletResponse, null); + return process(servletRequest, servletResponse, null); } /** @@ -479,9 +509,10 @@ public void process(HttpServletRequest servletRequest, HttpServletResponse servl * @param servletRequest The {@link HttpServletRequest} of the request * @param servletResponse The {@link HttpServletResponse} of the request * @param ownerKey A unique identifier of the owner (group) of this upload + * @return The processed {@link UploadInfo} or null if no upload was involved or an error occurred * @throws IOException When saving bytes or information of this requests fails */ - public void process( + public UploadInfo process( HttpServletRequest servletRequest, HttpServletResponse servletResponse, String ownerKey) throws IOException { Objects.requireNonNull(servletRequest, "The HTTP Servlet request cannot be null"); @@ -496,15 +527,24 @@ public void process( new TusServletRequest(servletRequest, isChunkedTransferDecodingEnabled); TusServletResponse response = new TusServletResponse(servletResponse); + UploadInfo processedUploadInfo = null; + boolean wasInProgress = checkWasInProgress(request, ownerKey); + try (UploadLock lock = acquireUploadLock(method, request.getRequestURI())) { - processLockedRequest(method, request, response, ownerKey); + processedUploadInfo = processLockedRequest(method, request, response, ownerKey); } catch (TusException e) { log.error("Unable to lock upload for request URI " + request.getRequestURI(), e); response.setHeader(HttpHeader.CONTENT_LENGTH, null); response.sendError(e.getStatus(), e.getMessage()); } + + if (wasInProgress && processedUploadInfo != null && !processedUploadInfo.isUploadInProgress()) { + notifyUploadCompletionListeners(processedUploadInfo); + } + + return processedUploadInfo; } protected UploadLock acquireUploadLock(HttpMethod method, String requestUri) @@ -539,6 +579,81 @@ protected UploadLock acquireUploadLock(HttpMethod method, String requestUri) return lock; } + /** + * Method to retrieve the bytes that were uploaded to a specific upload represented by {@link + * UploadInfo}. Validates the owner key under an exclusive upload lock. + * + * @param uploadInfo The upload info representing the upload + * @return An {@link InputStream} that will stream the uploaded bytes, or null if uploadInfo is + * null, not found, or the owner key does not match + * @throws IOException When retrieving the uploaded bytes fails + * @throws TusException When the upload is still in progress or cannot be found + */ + public InputStream getUploadedBytes(UploadInfo uploadInfo) throws IOException, TusException { + if (uploadInfo == null || uploadInfo.getId() == null) { + return null; + } + return getUploadedBytes(uploadInfo.getId(), uploadInfo.getOwnerKey()); + } + + /** + * Method to retrieve the bytes that were uploaded to a specific upload represented by {@link + * UploadInfo} and matching the given owner key. + * + * @param uploadInfo The upload info representing the upload + * @param ownerKey The expected owner key of the upload + * @return An {@link InputStream} that will stream the uploaded bytes, or null if uploadInfo is + * null, not found, or the owner key does not match + * @throws IOException When retrieving the uploaded bytes fails + * @throws TusException When the upload is still in progress or cannot be found + */ + public InputStream getUploadedBytes(UploadInfo uploadInfo, String ownerKey) + throws IOException, TusException { + if (uploadInfo == null || uploadInfo.getId() == null) { + return null; + } + return getUploadedBytes(uploadInfo.getId(), ownerKey); + } + + /** + * Method to retrieve the bytes that were uploaded to a specific upload represented by {@link + * UploadId}. + * + * @param uploadId The ID of the upload + * @return An {@link InputStream} that will stream the uploaded bytes, or null if uploadId is null + * or not found + * @throws IOException When retrieving the uploaded bytes fails + * @throws TusException When the upload is still in progress or cannot be found + */ + public InputStream getUploadedBytes(UploadId uploadId) throws IOException, TusException { + return getUploadedBytes(uploadId, null); + } + + /** + * Method to retrieve the bytes that were uploaded to a specific upload represented by {@link + * UploadId} and matching the given owner key. + * + * @param uploadId The ID of the upload + * @param ownerKey The expected owner key of the upload + * @return An {@link InputStream} that will stream the uploaded bytes, or null if uploadId is + * null, not found, or the owner key does not match + * @throws IOException When retrieving the uploaded bytes fails + * @throws TusException When the upload is still in progress or cannot be found + */ + public InputStream getUploadedBytes(UploadId uploadId, String ownerKey) + throws IOException, TusException { + if (uploadId == null) { + return null; + } + try (UploadLock lock = uploadLockingService.lockUploadByUri(uploadId.toString())) { + UploadInfo storedInfo = uploadStorageService.getUploadInfo(uploadId); + if (storedInfo == null || !Objects.equals(storedInfo.getOwnerKey(), ownerKey)) { + return null; + } + return uploadStorageService.getUploadedBytes(uploadId); + } + } + /** * Method to retrieve the bytes that were uploaded to a specific upload URI. * @@ -569,6 +684,42 @@ public InputStream getUploadedBytes(String uploadUri, String ownerKey) } } + /** + * Get the information on the upload corresponding to the given upload ID. + * + * @param uploadId The ID of the upload + * @return Information on the upload, or null if not found + * @throws IOException When retrieving the upload information fails + * @throws TusException When the upload is still in progress or cannot be found + */ + public UploadInfo getUploadInfo(UploadId uploadId) throws IOException, TusException { + return getUploadInfo(uploadId, null); + } + + /** + * Get the information on the upload corresponding to the given upload ID and matching the given + * owner key. + * + * @param uploadId The ID of the upload + * @param ownerKey The expected owner key of the upload + * @return Information on the upload, or null if not found or the owner key does not match + * @throws IOException When retrieving the upload information fails + * @throws TusException When the upload is still in progress or cannot be found + */ + public UploadInfo getUploadInfo(UploadId uploadId, String ownerKey) + throws IOException, TusException { + if (uploadId == null) { + return null; + } + try (UploadLock lock = uploadLockingService.lockUploadByUri(uploadId.toString())) { + UploadInfo storedInfo = uploadStorageService.getUploadInfo(uploadId); + if (storedInfo == null || !Objects.equals(storedInfo.getOwnerKey(), ownerKey)) { + return null; + } + return storedInfo; + } + } + /** * Get the information on the upload corresponding to the given upload URI. * @@ -598,6 +749,69 @@ public UploadInfo getUploadInfo(String uploadUri, String ownerKey) } } + /** + * Method to delete an upload associated with the given {@link UploadInfo}. Invoke this method if + * you no longer need the upload. Validates the owner key under an exclusive upload lock. + * + * @param uploadInfo The upload info representing the upload to delete + * @throws IOException When deleting the upload fails + * @throws TusException When the upload cannot be found or deleted + */ + public void deleteUpload(UploadInfo uploadInfo) throws IOException, TusException { + if (uploadInfo != null && uploadInfo.getId() != null) { + deleteUpload(uploadInfo.getId(), uploadInfo.getOwnerKey()); + } + } + + /** + * Method to delete an upload associated with the given {@link UploadInfo} and matching the given + * owner key. Invoke this method if you no longer need the upload. + * + * @param uploadInfo The upload info representing the upload to delete + * @param ownerKey The expected owner key of the upload + * @throws IOException When deleting the upload fails + * @throws TusException When the upload cannot be found or deleted + */ + public void deleteUpload(UploadInfo uploadInfo, String ownerKey) + throws IOException, TusException { + if (uploadInfo != null && uploadInfo.getId() != null) { + deleteUpload(uploadInfo.getId(), ownerKey); + } + } + + /** + * Method to delete an upload associated with the given {@link UploadId}. Invoke this method if + * you no longer need the upload. + * + * @param uploadId The ID of the upload to delete + * @throws IOException When deleting the upload fails + * @throws TusException When the upload cannot be locked + */ + public void deleteUpload(UploadId uploadId) throws IOException, TusException { + deleteUpload(uploadId, null); + } + + /** + * Method to delete an upload associated with the given {@link UploadId} and matching the given + * owner key. Invoke this method if you no longer need the upload. + * + * @param uploadId The ID of the upload to delete + * @param ownerKey The expected owner key of the upload + * @throws IOException When deleting the upload fails + * @throws TusException When the upload cannot be locked + */ + public void deleteUpload(UploadId uploadId, String ownerKey) throws IOException, TusException { + if (uploadId == null) { + return; + } + try (UploadLock lock = uploadLockingService.lockUploadByUri(uploadId.toString())) { + UploadInfo storedInfo = uploadStorageService.getUploadInfo(uploadId); + if (storedInfo != null && Objects.equals(storedInfo.getOwnerKey(), ownerKey)) { + uploadStorageService.terminateUpload(storedInfo); + } + } + } + /** * Method to delete an upload associated with the given upload URL. Invoke this method if you no * longer need the upload. @@ -634,7 +848,7 @@ public void cleanup() throws IOException { uploadStorageService.cleanupExpiredUploads(uploadLockingService); } - protected void processLockedRequest( + protected UploadInfo processLockedRequest( HttpMethod method, TusServletRequest request, TusServletResponse response, String ownerKey) throws IOException { ProtocolVersion detectedVersion = detectProtocolVersion(request); @@ -644,8 +858,58 @@ protected void processLockedRequest( executeProcessingByFeatures(method, request, response, ownerKey, detectedVersion); + return resolveUploadInfo(request, response, ownerKey); + } catch (TusException e) { processTusException(method, request, response, ownerKey, e, detectedVersion); + return null; + } + } + + private UploadInfo resolveUploadInfo( + TusServletRequest request, TusServletResponse response, String ownerKey) throws IOException { + String uploadUri = response != null ? response.getHeader(HttpHeader.LOCATION) : null; + if (StringUtils.isBlank(uploadUri) && request != null) { + if (Utils.isCreationEndpoint(request, uploadStorageService)) { + return null; + } + uploadUri = request.getRequestURI(); + } + if (StringUtils.isNotBlank(uploadUri) && uploadStorageService != null) { + return uploadStorageService.getUploadInfo(uploadUri, ownerKey); + } + return null; + } + + private boolean checkWasInProgress(TusServletRequest request, String ownerKey) { + if (request == null || uploadStorageService == null) { + return true; + } + try { + UploadInfo uploadInfo = resolveUploadInfo(request, null, ownerKey); + if (uploadInfo != null) { + return uploadInfo.isUploadInProgress(); + } + } catch (Exception e) { + log.debug("Error checking initial upload progress state: {}", e.getMessage()); + } + return true; + } + + protected void notifyUploadCompletionListeners(UploadInfo uploadInfo) { + if (uploadInfo == null || uploadCompletionListeners.isEmpty()) { + return; + } + for (UploadCompletionListener listener : uploadCompletionListeners) { + try { + listener.onUploadComplete(uploadInfo, this); + } catch (Throwable t) { + log.error( + "Error executing upload completion listener for upload ID {}: {}", + uploadInfo.getId(), + t.getMessage(), + t); + } } } diff --git a/src/main/java/me/desair/tus/server/UploadCompletionListener.java b/src/main/java/me/desair/tus/server/UploadCompletionListener.java new file mode 100644 index 00000000..93124dd3 --- /dev/null +++ b/src/main/java/me/desair/tus/server/UploadCompletionListener.java @@ -0,0 +1,19 @@ +package me.desair.tus.server; + +import me.desair.tus.server.upload.UploadInfo; + +/** + * Functional interface for listening to upload completion events across both Tus 1.0.0 and IETF + * Resumable Uploads for HTTP (RUFH) protocols. + */ +@FunctionalInterface +public interface UploadCompletionListener { + + /** + * Invoked when an upload has successfully completed. + * + * @param uploadInfo the metadata and identifiers of the completed upload + * @param tusFileUploadService the service instance that processed the upload + */ + void onUploadComplete(UploadInfo uploadInfo, TusFileUploadService tusFileUploadService); +} diff --git a/src/test/java/me/desair/tus/server/AbstractITRufhProtocol.java b/src/test/java/me/desair/tus/server/AbstractITRufhProtocol.java index f27fd612..e818de35 100644 --- a/src/test/java/me/desair/tus/server/AbstractITRufhProtocol.java +++ b/src/test/java/me/desair/tus/server/AbstractITRufhProtocol.java @@ -3,6 +3,7 @@ import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; @@ -11,6 +12,8 @@ import jakarta.servlet.http.HttpServletResponse; import java.io.InputStream; import java.nio.charset.StandardCharsets; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; import me.desair.tus.server.upload.UploadInfo; import org.apache.commons.io.IOUtils; import org.apache.commons.lang3.StringUtils; @@ -724,6 +727,80 @@ public void testUploadWithAbsoluteUploadUriWithPath() throws Exception { } } + @Test + public void testUploadCompletionListenerRufhPostCreation() throws Exception { + AtomicInteger listenerCallCount = new AtomicInteger(0); + AtomicReference completedUploadInfo = new AtomicReference<>(); + + tusFileUploadService.withUploadCompletionListener( + (uploadInfo, service) -> { + listenerCallCount.incrementAndGet(); + completedUploadInfo.set(uploadInfo); + }); + + String uploadContent = "rufh-creation-complete"; + + servletRequest.setMethod("POST"); + servletRequest.setRequestURI(UPLOAD_URI); + servletRequest.addHeader(HttpHeader.UPLOAD_COMPLETE, "?1"); + servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, uploadContent.length()); + servletRequest.addHeader(HttpHeader.CONTENT_TYPE, "application/octet-stream"); + servletRequest.setContent(uploadContent.getBytes(StandardCharsets.UTF_8)); + + UploadInfo createdInfo = + tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY); + assertResponseStatus(HttpServletResponse.SC_OK); + assertNotNull(createdInfo); + assertEquals(1, listenerCallCount.get()); + assertNotNull(completedUploadInfo.get()); + + try (InputStream stream = tusFileUploadService.getUploadedBytes(completedUploadInfo.get())) { + assertThat(IOUtils.toString(stream, StandardCharsets.UTF_8), is(uploadContent)); + } + } + + @Test + public void testUploadCompletionListenerRufhPatchAppend() throws Exception { + AtomicInteger listenerCallCount = new AtomicInteger(0); + AtomicReference completedUploadInfo = new AtomicReference<>(); + + tusFileUploadService.withUploadCompletionListener( + (uploadInfo, service) -> { + listenerCallCount.incrementAndGet(); + completedUploadInfo.set(uploadInfo); + }); + + // Step 1: Create incomplete RUFH upload + servletRequest.setMethod("POST"); + servletRequest.setRequestURI(UPLOAD_URI); + servletRequest.addHeader(HttpHeader.UPLOAD_COMPLETE, "?0"); + UploadInfo createdInfo = + tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY); + assertResponseStatus(HttpServletResponse.SC_CREATED); + assertEquals(0, listenerCallCount.get()); + + String uploadLocation = servletResponse.getHeader(HttpHeader.LOCATION); + + // Step 2: Append with Upload-Complete: ?1 + String uploadContent = "rufh-patch-complete"; + reset(); + servletRequest.setMethod("PATCH"); + servletRequest.setRequestURI(uploadLocation); + servletRequest.addHeader(HttpHeader.UPLOAD_OFFSET, "0"); + servletRequest.addHeader(HttpHeader.UPLOAD_COMPLETE, "?1"); + servletRequest.addHeader(HttpHeader.CONTENT_TYPE, HttpHeader.CONTENT_TYPE_PARTIAL_UPLOAD); + servletRequest.setContent(uploadContent.getBytes(StandardCharsets.UTF_8)); + + UploadInfo patchInfo = tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY); + assertResponseStatus(HttpServletResponse.SC_OK); + assertNotNull(patchInfo); + assertEquals(1, listenerCallCount.get()); + + try (InputStream stream = tusFileUploadService.getUploadedBytes(completedUploadInfo.get())) { + assertThat(IOUtils.toString(stream, StandardCharsets.UTF_8), is(uploadContent)); + } + } + // =============================================================================================== // ASSERTION HELPERS // =============================================================================================== diff --git a/src/test/java/me/desair/tus/server/AbstractITTusFileUploadService.java b/src/test/java/me/desair/tus/server/AbstractITTusFileUploadService.java index ba3f5a9d..b51ec433 100644 --- a/src/test/java/me/desair/tus/server/AbstractITTusFileUploadService.java +++ b/src/test/java/me/desair/tus/server/AbstractITTusFileUploadService.java @@ -8,7 +8,9 @@ import static org.hamcrest.Matchers.containsInAnyOrder; import static org.hamcrest.Matchers.greaterThan; import static org.hamcrest.collection.IsMapContaining.hasEntry; +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 static org.junit.Assert.fail; @@ -22,6 +24,8 @@ import java.util.Arrays; import java.util.Locale; import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; import me.desair.tus.server.exception.TusException; import me.desair.tus.server.upload.UploadInfo; import me.desair.tus.server.util.Utils; @@ -2076,6 +2080,193 @@ public void testUploadWithAbsoluteUploadUriWithPath() throws Exception { } } + @Test + public void testUploadCompletionListenerTusPatch() throws Exception { + AtomicInteger listenerCallCount = new AtomicInteger(0); + AtomicReference completedUploadInfo = new AtomicReference<>(); + AtomicReference callbackService = new AtomicReference<>(); + + tusFileUploadService.withUploadCompletionListener( + (uploadInfo, service) -> { + listenerCallCount.incrementAndGet(); + completedUploadInfo.set(uploadInfo); + callbackService.set(service); + }); + + String uploadContent = "1234567890"; + + // Step 1: Create upload with length 10 + servletRequest.setMethod("POST"); + servletRequest.setRequestURI(UPLOAD_URI); + servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, uploadContent.length()); + servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0"); + + UploadInfo createdInfo = + tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY); + assertResponseStatus(HttpServletResponse.SC_CREATED); + assertNotNull(createdInfo); + assertEquals(0, listenerCallCount.get()); + + String uploadLocation = servletResponse.getHeader(HttpHeader.LOCATION); + + // Step 2: Upload chunk 1 (5 bytes) + reset(); + servletRequest.setMethod("PATCH"); + servletRequest.setRequestURI(uploadLocation); + servletRequest.addHeader(HttpHeader.CONTENT_TYPE, "application/offset+octet-stream"); + servletRequest.addHeader(HttpHeader.UPLOAD_OFFSET, 0); + servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0"); + servletRequest.setContent(uploadContent.substring(0, 5).getBytes(StandardCharsets.UTF_8)); + + UploadInfo patch1Info = + tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY); + assertResponseStatus(HttpServletResponse.SC_NO_CONTENT); + assertNotNull(patch1Info); + assertEquals(0, listenerCallCount.get()); + + // Step 3: Upload chunk 2 (remaining 5 bytes) - should complete upload and fire listener + reset(); + servletRequest.setMethod("PATCH"); + servletRequest.setRequestURI(uploadLocation); + servletRequest.addHeader(HttpHeader.CONTENT_TYPE, "application/offset+octet-stream"); + servletRequest.addHeader(HttpHeader.UPLOAD_OFFSET, 5); + servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0"); + servletRequest.setContent(uploadContent.substring(5).getBytes(StandardCharsets.UTF_8)); + + UploadInfo patch2Info = + tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY); + assertResponseStatus(HttpServletResponse.SC_NO_CONTENT); + assertNotNull(patch2Info); + assertEquals(1, listenerCallCount.get()); + assertNotNull(completedUploadInfo.get()); + assertEquals(createdInfo.getId(), completedUploadInfo.get().getId()); + assertEquals(tusFileUploadService, callbackService.get()); + + // Step 4: Verify uploaded bytes can be retrieved by UploadInfo + try (InputStream stream = tusFileUploadService.getUploadedBytes(completedUploadInfo.get())) { + assertThat(IOUtils.toString(stream, StandardCharsets.UTF_8), is(uploadContent)); + } + + // Step 5: Send subsequent HEAD request on completed upload - must not trigger listener again + reset(); + servletRequest.setMethod("HEAD"); + servletRequest.setRequestURI(uploadLocation); + servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0"); + + UploadInfo headInfo = tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY); + assertResponseStatus(HttpServletResponse.SC_NO_CONTENT); + assertNotNull(headInfo); + assertEquals(1, listenerCallCount.get()); + } + + @Test + public void testUploadCompletionListenerTusCreationWithUpload() throws Exception { + AtomicInteger listenerCallCount = new AtomicInteger(0); + AtomicReference completedUploadInfo = new AtomicReference<>(); + AtomicReference callbackService = new AtomicReference<>(); + + tusFileUploadService.withUploadCompletionListener( + (uploadInfo, service) -> { + listenerCallCount.incrementAndGet(); + completedUploadInfo.set(uploadInfo); + callbackService.set(service); + }); + + String uploadContent = "creation-with-upload-payload"; + + servletRequest.setMethod("POST"); + servletRequest.setRequestURI(UPLOAD_URI); + servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, uploadContent.length()); + servletRequest.addHeader(HttpHeader.CONTENT_LENGTH, uploadContent.length()); + servletRequest.addHeader(HttpHeader.CONTENT_TYPE, "application/offset+octet-stream"); + servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0"); + servletRequest.setContent(uploadContent.getBytes(StandardCharsets.UTF_8)); + + UploadInfo createdInfo = + tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY); + assertResponseStatus(HttpServletResponse.SC_CREATED); + assertNotNull(createdInfo); + assertEquals(1, listenerCallCount.get()); + assertNotNull(completedUploadInfo.get()); + assertEquals(createdInfo.getId(), completedUploadInfo.get().getId()); + assertEquals(tusFileUploadService, callbackService.get()); + + // Verify uploaded bytes via UploadInfo + try (InputStream stream = tusFileUploadService.getUploadedBytes(completedUploadInfo.get())) { + assertThat(IOUtils.toString(stream, StandardCharsets.UTF_8), is(uploadContent)); + } + + // Clean up via UploadInfo + tusFileUploadService.deleteUpload(completedUploadInfo.get()); + } + + @Test + public void testUploadCompletionListenerTusConcatenation() throws Exception { + String part1Content = "part1-"; + String part2Content = "part2"; + + // 1. Create and upload partial 1 + servletRequest.setMethod("POST"); + servletRequest.setRequestURI(UPLOAD_URI); + servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, part1Content.length()); + servletRequest.addHeader(HttpHeader.UPLOAD_CONCAT, "partial"); + servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0"); + tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY); + String part1Uri = servletResponse.getHeader(HttpHeader.LOCATION); + + reset(); + servletRequest.setMethod("PATCH"); + servletRequest.setRequestURI(part1Uri); + servletRequest.addHeader(HttpHeader.CONTENT_TYPE, "application/offset+octet-stream"); + servletRequest.addHeader(HttpHeader.UPLOAD_OFFSET, 0); + servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0"); + servletRequest.setContent(part1Content.getBytes(StandardCharsets.UTF_8)); + tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY); + + // 2. Create and upload partial 2 + reset(); + servletRequest.setMethod("POST"); + servletRequest.setRequestURI(UPLOAD_URI); + servletRequest.addHeader(HttpHeader.UPLOAD_LENGTH, part2Content.length()); + servletRequest.addHeader(HttpHeader.UPLOAD_CONCAT, "partial"); + servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0"); + tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY); + String part2Uri = servletResponse.getHeader(HttpHeader.LOCATION); + + reset(); + servletRequest.setMethod("PATCH"); + servletRequest.setRequestURI(part2Uri); + servletRequest.addHeader(HttpHeader.CONTENT_TYPE, "application/offset+octet-stream"); + servletRequest.addHeader(HttpHeader.UPLOAD_OFFSET, 0); + servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0"); + servletRequest.setContent(part2Content.getBytes(StandardCharsets.UTF_8)); + tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY); + + // 3. Register listener and execute final concatenation + AtomicInteger listenerCallCount = new AtomicInteger(0); + AtomicReference completedUploadInfo = new AtomicReference<>(); + tusFileUploadService.withUploadCompletionListener( + (uploadInfo, service) -> { + listenerCallCount.incrementAndGet(); + completedUploadInfo.set(uploadInfo); + }); + + reset(); + servletRequest.setMethod("POST"); + servletRequest.setRequestURI(UPLOAD_URI); + servletRequest.addHeader(HttpHeader.UPLOAD_CONCAT, "final;" + part1Uri + " " + part2Uri); + servletRequest.addHeader(HttpHeader.TUS_RESUMABLE, "1.0.0"); + + UploadInfo finalInfo = tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY); + assertResponseStatus(HttpServletResponse.SC_CREATED); + assertNotNull(finalInfo); + assertEquals(1, listenerCallCount.get()); + + try (InputStream stream = tusFileUploadService.getUploadedBytes(completedUploadInfo.get())) { + assertThat(IOUtils.toString(stream, StandardCharsets.UTF_8), is(part1Content + part2Content)); + } + } + protected void assertResponseHeader(final String header, final String value) { assertThat(servletResponse.getHeader(header), is(value)); } diff --git a/src/test/java/me/desair/tus/server/UploadCompletionListenerTest.java b/src/test/java/me/desair/tus/server/UploadCompletionListenerTest.java new file mode 100644 index 00000000..82d75f13 --- /dev/null +++ b/src/test/java/me/desair/tus/server/UploadCompletionListenerTest.java @@ -0,0 +1,479 @@ +package me.desair.tus.server; + +import static org.hamcrest.Matchers.is; +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.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import jakarta.servlet.http.HttpServletResponse; +import java.io.ByteArrayInputStream; +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.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import me.desair.tus.server.upload.UploadId; +import me.desair.tus.server.upload.UploadInfo; +import me.desair.tus.server.upload.UploadStorageService; +import org.apache.commons.io.FileUtils; +import org.apache.commons.io.IOUtils; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +/** + * Unit tests for {@link UploadCompletionListener} and related methods in {@link + * TusFileUploadService}. + */ +public class UploadCompletionListenerTest { + + private Path storagePath; + private TusFileUploadService tusFileUploadService; + + @Before + public void setUp() throws Exception { + storagePath = Files.createTempDirectory("tus-listener-test-"); + tusFileUploadService = + new TusFileUploadService().withStoragePath(storagePath.toString()).withUploadUri("/files"); + } + + @After + public void tearDown() throws Exception { + if (tusFileUploadService != null) { + tusFileUploadService.close(); + } + if (storagePath != null && Files.exists(storagePath)) { + FileUtils.deleteDirectory(storagePath.toFile()); + } + } + + @Test + public void testRegisterAndAddListenersNullSafe() { + TusFileUploadService service = new TusFileUploadService(); + service.withUploadCompletionListener(null); + service.addUploadCompletionListener(null); + + AtomicInteger callCount = new AtomicInteger(0); + UploadCompletionListener listener = (info, svc) -> callCount.incrementAndGet(); + + service.withUploadCompletionListener(listener); + service.addUploadCompletionListener(listener); + + UploadInfo completedInfo = new UploadInfo(); + completedInfo.setId(new UploadId("test-id")); + completedInfo.setLength(100L); + completedInfo.setOffset(100L); + + service.notifyUploadCompletionListeners(completedInfo); + assertEquals(2, callCount.get()); + } + + @Test + public void testGetUploadedBytesAndTerminateNullSafe() throws Exception { + UploadStorageService mockStorage = mock(UploadStorageService.class); + UploadId uploadId = new UploadId("abc-123"); + UploadInfo info = new UploadInfo(); + info.setId(uploadId); + info.setOwnerKey("OWNER_TEST"); + + InputStream mockStream = + new ByteArrayInputStream("hello world".getBytes(StandardCharsets.UTF_8)); + when(mockStorage.getUploadInfo(uploadId)).thenReturn(info); + when(mockStorage.getUploadedBytes(uploadId)).thenReturn(mockStream); + + TusFileUploadService service = new TusFileUploadService().withUploadStorageService(mockStorage); + + // Null safety checks for UploadInfo overloads + assertNull(service.getUploadedBytes((UploadInfo) null)); + assertNull(service.getUploadedBytes((UploadInfo) null, "anyOwner")); + UploadInfo nullIdInfo = new UploadInfo(); + assertNull(service.getUploadedBytes(nullIdInfo)); + assertNull(service.getUploadedBytes(nullIdInfo, "anyOwner")); + service.deleteUpload((UploadInfo) null); + service.deleteUpload((UploadInfo) null, "anyOwner"); + service.deleteUpload(nullIdInfo); + service.deleteUpload(nullIdInfo, "anyOwner"); + + // Null safety checks for UploadId overloads + assertNull(service.getUploadedBytes((UploadId) null)); + assertNull(service.getUploadedBytes((UploadId) null, "anyOwner")); + assertNull(service.getUploadInfo((UploadId) null)); + assertNull(service.getUploadInfo((UploadId) null, "anyOwner")); + service.deleteUpload((UploadId) null); + service.deleteUpload((UploadId) null, "anyOwner"); + + // Legitimate calls with matching owner + InputStream result = service.getUploadedBytes(info); + assertNotNull(result); + assertEquals("hello world", IOUtils.toString(result, StandardCharsets.UTF_8)); + + UploadInfo fetchedInfo = service.getUploadInfo(uploadId, "OWNER_TEST"); + assertNotNull(fetchedInfo); + assertEquals(uploadId, fetchedInfo.getId()); + + // Call remaining overloads with ownerKey = null setup + UploadId unownedId = new UploadId("unowned-123"); + UploadInfo unownedInfo = new UploadInfo(); + unownedInfo.setId(unownedId); + unownedInfo.setOwnerKey(null); + when(mockStorage.getUploadInfo(unownedId)).thenReturn(unownedInfo); + when(mockStorage.getUploadedBytes(unownedId)) + .thenReturn(new ByteArrayInputStream("unowned bytes".getBytes(StandardCharsets.UTF_8))); + + InputStream unownedStream = service.getUploadedBytes(unownedId); + assertNotNull(unownedStream); + assertEquals("unowned bytes", IOUtils.toString(unownedStream, StandardCharsets.UTF_8)); + + UploadInfo unownedFetched = service.getUploadInfo(unownedId); + assertNotNull(unownedFetched); + assertEquals(unownedId, unownedFetched.getId()); + + service.deleteUpload(unownedId); + verify(mockStorage, times(1)).terminateUpload(unownedInfo); + + service.deleteUpload(info, "OWNER_TEST"); + verify(mockStorage, times(1)).terminateUpload(info); + + service.deleteUpload(info); + verify(mockStorage, times(2)).terminateUpload(info); + } + + @Test + public void testOwnerKeyIsolationWithUploadInfo() throws Exception { + String aliceOwner = "USER_ALICE"; + String bobOwner = "USER_BOB"; + byte[] payload = "confidential-alice-payload".getBytes(StandardCharsets.UTF_8); + + // 1. Alice creates upload + MockHttpServletRequest createRequest = new MockHttpServletRequest("POST", "/files"); + createRequest.addHeader(HttpHeader.TUS_RESUMABLE, TusFileUploadService.TUS_API_VERSION); + createRequest.addHeader(HttpHeader.UPLOAD_LENGTH, String.valueOf(payload.length)); + MockHttpServletResponse createResponse = new MockHttpServletResponse(); + + UploadInfo createdInfo = + tusFileUploadService.process(createRequest, createResponse, aliceOwner); + assertNotNull(createdInfo); + assertEquals(aliceOwner, createdInfo.getOwnerKey()); + + String location = createResponse.getHeader(HttpHeader.LOCATION); + + // 2. Alice uploads bytes + MockHttpServletRequest patchRequest = new MockHttpServletRequest("PATCH", location); + patchRequest.addHeader(HttpHeader.TUS_RESUMABLE, TusFileUploadService.TUS_API_VERSION); + patchRequest.addHeader(HttpHeader.UPLOAD_OFFSET, "0"); + patchRequest.addHeader(HttpHeader.CONTENT_TYPE, "application/offset+octet-stream"); + patchRequest.setContent(payload); + MockHttpServletResponse patchResponse = new MockHttpServletResponse(); + + UploadInfo patchedInfo = tusFileUploadService.process(patchRequest, patchResponse, aliceOwner); + assertNotNull(patchedInfo); + + // 3. Security verification: Forged UploadInfo instances + UploadInfo forgedBobInfo = new UploadInfo(); + forgedBobInfo.setId(createdInfo.getId()); + forgedBobInfo.setOwnerKey(bobOwner); + + UploadInfo forgedAnonInfo = new UploadInfo(); + forgedAnonInfo.setId(createdInfo.getId()); + forgedAnonInfo.setOwnerKey(null); + + // Mismatched owner key on UploadInfo must return null and leak no data + assertNull(tusFileUploadService.getUploadedBytes(forgedBobInfo)); + assertNull(tusFileUploadService.getUploadedBytes(forgedBobInfo, bobOwner)); + assertNull(tusFileUploadService.getUploadedBytes(createdInfo, bobOwner)); + assertNull(tusFileUploadService.getUploadedBytes(forgedAnonInfo)); + assertNull(tusFileUploadService.getUploadedBytes(forgedAnonInfo, null)); + + // 4. Legitimate access: Alice retrieves her uploaded bytes + try (InputStream aliceStream = tusFileUploadService.getUploadedBytes(createdInfo)) { + assertNotNull(aliceStream); + assertEquals( + "confidential-alice-payload", IOUtils.toString(aliceStream, StandardCharsets.UTF_8)); + } + + try (InputStream aliceStreamExplicit = + tusFileUploadService.getUploadedBytes(createdInfo, aliceOwner)) { + assertNotNull(aliceStreamExplicit); + assertEquals( + "confidential-alice-payload", + IOUtils.toString(aliceStreamExplicit, StandardCharsets.UTF_8)); + } + + // 5. Security verification: Unauthorized deletion attempts + tusFileUploadService.deleteUpload(forgedBobInfo); + tusFileUploadService.deleteUpload(createdInfo, bobOwner); + + // Verify Alice's upload is still intact + try (InputStream streamAfterBobDelete = tusFileUploadService.getUploadedBytes(createdInfo)) { + assertNotNull("Upload must not be deleted by unauthorized owner", streamAfterBobDelete); + } + + // 6. Legitimate deletion by Alice + tusFileUploadService.deleteUpload(createdInfo); + + // Verify upload is gone + assertNull(tusFileUploadService.getUploadedBytes(createdInfo)); + } + + @Test + public void testOwnerKeyIsolationWithUploadId() throws Exception { + String aliceOwner = "USER_ALICE"; + String bobOwner = "USER_BOB"; + byte[] payload = "confidential-upload-id-payload".getBytes(StandardCharsets.UTF_8); + + // 1. Alice creates upload + MockHttpServletRequest createRequest = new MockHttpServletRequest("POST", "/files"); + createRequest.addHeader(HttpHeader.TUS_RESUMABLE, TusFileUploadService.TUS_API_VERSION); + createRequest.addHeader(HttpHeader.UPLOAD_LENGTH, String.valueOf(payload.length)); + MockHttpServletResponse createResponse = new MockHttpServletResponse(); + + UploadInfo createdInfo = + tusFileUploadService.process(createRequest, createResponse, aliceOwner); + assertNotNull(createdInfo); + UploadId uploadId = createdInfo.getId(); + + String location = createResponse.getHeader(HttpHeader.LOCATION); + + // 2. Alice uploads bytes + MockHttpServletRequest patchRequest = new MockHttpServletRequest("PATCH", location); + patchRequest.addHeader(HttpHeader.TUS_RESUMABLE, TusFileUploadService.TUS_API_VERSION); + patchRequest.addHeader(HttpHeader.UPLOAD_OFFSET, "0"); + patchRequest.addHeader(HttpHeader.CONTENT_TYPE, "application/offset+octet-stream"); + patchRequest.setContent(payload); + MockHttpServletResponse patchResponse = new MockHttpServletResponse(); + + UploadInfo patchedInfo = tusFileUploadService.process(patchRequest, patchResponse, aliceOwner); + assertNotNull(patchedInfo); + + // 3. Security verification: Bob attempts to read bytes and metadata by UploadId + assertNull(tusFileUploadService.getUploadedBytes(uploadId, bobOwner)); + assertNull(tusFileUploadService.getUploadedBytes(uploadId, null)); + assertNull(tusFileUploadService.getUploadedBytes(uploadId)); // Default null owner + assertNull(tusFileUploadService.getUploadInfo(uploadId, bobOwner)); + assertNull(tusFileUploadService.getUploadInfo(uploadId, null)); + assertNull(tusFileUploadService.getUploadInfo(uploadId)); // Default null owner + + // 4. Legitimate access by Alice + UploadInfo aliceInfo = tusFileUploadService.getUploadInfo(uploadId, aliceOwner); + assertNotNull(aliceInfo); + assertEquals(uploadId, aliceInfo.getId()); + + try (InputStream is = tusFileUploadService.getUploadedBytes(uploadId, aliceOwner)) { + assertNotNull(is); + assertEquals("confidential-upload-id-payload", IOUtils.toString(is, StandardCharsets.UTF_8)); + } + + // 5. Security verification: Bob attempts to delete Alice's upload by UploadId + tusFileUploadService.deleteUpload(uploadId, bobOwner); + tusFileUploadService.deleteUpload(uploadId); // Default null owner + + // Verify upload still exists + assertNotNull(tusFileUploadService.getUploadInfo(uploadId, aliceOwner)); + + // 6. Legitimate deletion by Alice + tusFileUploadService.deleteUpload(uploadId, aliceOwner); + assertNull(tusFileUploadService.getUploadInfo(uploadId, aliceOwner)); + assertNull(tusFileUploadService.getUploadedBytes(uploadId, aliceOwner)); + } + + @Test + public void testListenerReceivesServiceInstanceAndReadsBytes() throws Exception { + AtomicReference receivedService = new AtomicReference<>(); + AtomicReference receivedInfo = new AtomicReference<>(); + AtomicBoolean bytesReadMatch = new AtomicBoolean(false); + + byte[] payload = "completed-test-payload".getBytes(StandardCharsets.UTF_8); + + tusFileUploadService.withUploadCompletionListener( + (info, svc) -> { + receivedInfo.set(info); + receivedService.set(svc); + try (InputStream is = svc.getUploadedBytes(info)) { + byte[] readBytes = IOUtils.toByteArray(is); + bytesReadMatch.set( + new String(readBytes, StandardCharsets.UTF_8).equals("completed-test-payload")); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + + // 1. Create upload via POST + MockHttpServletRequest createRequest = new MockHttpServletRequest("POST", "/files"); + createRequest.addHeader(HttpHeader.TUS_RESUMABLE, TusFileUploadService.TUS_API_VERSION); + createRequest.addHeader(HttpHeader.UPLOAD_LENGTH, String.valueOf(payload.length)); + MockHttpServletResponse createResponse = new MockHttpServletResponse(); + + UploadInfo createdInfo = tusFileUploadService.process(createRequest, createResponse); + assertNotNull(createdInfo); + assertNull(receivedInfo.get()); // Incomplete, should not have fired + + String location = createResponse.getHeader(HttpHeader.LOCATION); + assertNotNull(location); + + // 2. Upload full payload via PATCH + MockHttpServletRequest patchRequest = new MockHttpServletRequest("PATCH", location); + patchRequest.addHeader(HttpHeader.TUS_RESUMABLE, TusFileUploadService.TUS_API_VERSION); + patchRequest.addHeader(HttpHeader.UPLOAD_OFFSET, "0"); + patchRequest.addHeader(HttpHeader.CONTENT_TYPE, "application/offset+octet-stream"); + patchRequest.setContent(payload); + MockHttpServletResponse patchResponse = new MockHttpServletResponse(); + + UploadInfo patchedInfo = tusFileUploadService.process(patchRequest, patchResponse); + assertNotNull(patchedInfo); + assertEquals(HttpServletResponse.SC_NO_CONTENT, patchResponse.getStatus()); + + // Verify listener was called with correct arguments + assertNotNull(receivedInfo.get()); + assertEquals(createdInfo.getId(), receivedInfo.get().getId()); + assertEquals(tusFileUploadService, receivedService.get()); + assertTrue(bytesReadMatch.get()); + } + + @Test + public void testListenerExceptionIsolation() throws Exception { + AtomicInteger listenerTwoCallCount = new AtomicInteger(0); + + tusFileUploadService + .withUploadCompletionListener( + (info, svc) -> { + throw new RuntimeException("Downstream failure in listener 1"); + }) + .addUploadCompletionListener( + (info, svc) -> { + listenerTwoCallCount.incrementAndGet(); + }); + + byte[] payload = "hello".getBytes(StandardCharsets.UTF_8); + + // Creation with upload (single request completion) + MockHttpServletRequest postRequest = new MockHttpServletRequest("POST", "/files"); + postRequest.addHeader(HttpHeader.TUS_RESUMABLE, TusFileUploadService.TUS_API_VERSION); + postRequest.addHeader(HttpHeader.UPLOAD_LENGTH, String.valueOf(payload.length)); + postRequest.addHeader(HttpHeader.CONTENT_LENGTH, String.valueOf(payload.length)); + postRequest.addHeader(HttpHeader.CONTENT_TYPE, "application/offset+octet-stream"); + postRequest.setContent(payload); + MockHttpServletResponse postResponse = new MockHttpServletResponse(); + + UploadInfo info = tusFileUploadService.process(postRequest, postResponse); + assertNotNull(info); + assertEquals(HttpServletResponse.SC_CREATED, postResponse.getStatus()); + assertEquals(1, listenerTwoCallCount.get()); + } + + @Test + public void testProcessReturnsNullOnOptionsAndError() throws Exception { + MockHttpServletRequest optionsRequest = new MockHttpServletRequest("OPTIONS", "/files"); + optionsRequest.addHeader(HttpHeader.TUS_RESUMABLE, TusFileUploadService.TUS_API_VERSION); + MockHttpServletResponse optionsResponse = new MockHttpServletResponse(); + + UploadInfo optionsInfo = tusFileUploadService.process(optionsRequest, optionsResponse); + assertNull(optionsInfo); + assertEquals(HttpServletResponse.SC_NO_CONTENT, optionsResponse.getStatus()); + + // Invalid PATCH request (non-existent upload) + MockHttpServletRequest badPatchRequest = + new MockHttpServletRequest("PATCH", "/files/non-existent-id"); + badPatchRequest.addHeader(HttpHeader.TUS_RESUMABLE, TusFileUploadService.TUS_API_VERSION); + badPatchRequest.addHeader(HttpHeader.UPLOAD_OFFSET, "0"); + badPatchRequest.addHeader(HttpHeader.CONTENT_TYPE, "application/offset+octet-stream"); + MockHttpServletResponse badPatchResponse = new MockHttpServletResponse(); + + UploadInfo badPatchInfo = tusFileUploadService.process(badPatchRequest, badPatchResponse); + assertNull(badPatchInfo); + assertEquals(HttpServletResponse.SC_NOT_FOUND, badPatchResponse.getStatus()); + } + + @Test + public void testNotifyUploadCompletionListenersNullSafe() { + TusFileUploadService service = new TusFileUploadService(); + // Verify no exception on null or empty + service.notifyUploadCompletionListeners(null); + + UploadInfo info = new UploadInfo(); + service.notifyUploadCompletionListeners(info); + } + + @Test + public void testIncompletePatchDoesNotTriggerListener() throws Exception { + AtomicInteger listenerCallCount = new AtomicInteger(0); + tusFileUploadService.withUploadCompletionListener( + (info, svc) -> listenerCallCount.incrementAndGet()); + + // 1. Create upload of length 20 + MockHttpServletRequest createRequest = new MockHttpServletRequest("POST", "/files"); + createRequest.addHeader(HttpHeader.TUS_RESUMABLE, TusFileUploadService.TUS_API_VERSION); + createRequest.addHeader(HttpHeader.UPLOAD_LENGTH, "20"); + MockHttpServletResponse createResponse = new MockHttpServletResponse(); + UploadInfo createdInfo = tusFileUploadService.process(createRequest, createResponse); + assertNotNull(createdInfo); + assertEquals(0, listenerCallCount.get()); + + String location = createResponse.getHeader(HttpHeader.LOCATION); + + // 2. Upload first 10 bytes via PATCH (partial chunk) + MockHttpServletRequest patch1 = new MockHttpServletRequest("PATCH", location); + patch1.addHeader(HttpHeader.TUS_RESUMABLE, TusFileUploadService.TUS_API_VERSION); + patch1.addHeader(HttpHeader.UPLOAD_OFFSET, "0"); + patch1.addHeader(HttpHeader.CONTENT_TYPE, "application/offset+octet-stream"); + patch1.setContent("0123456789".getBytes(StandardCharsets.UTF_8)); + MockHttpServletResponse patchResponse1 = new MockHttpServletResponse(); + UploadInfo patchInfo1 = tusFileUploadService.process(patch1, patchResponse1); + assertNotNull(patchInfo1); + assertEquals(Long.valueOf(10L), patchInfo1.getOffset()); + assertEquals(0, listenerCallCount.get()); + + // 3. Send HEAD request - should return upload info but not trigger listener + MockHttpServletRequest headRequest = new MockHttpServletRequest("HEAD", location); + headRequest.addHeader(HttpHeader.TUS_RESUMABLE, TusFileUploadService.TUS_API_VERSION); + MockHttpServletResponse headResponse = new MockHttpServletResponse(); + UploadInfo headInfo = tusFileUploadService.process(headRequest, headResponse); + assertNotNull(headInfo); + assertEquals(0, listenerCallCount.get()); + + // 4. Upload remaining 10 bytes via PATCH - should trigger listener exactly once + MockHttpServletRequest patch2 = new MockHttpServletRequest("PATCH", location); + patch2.addHeader(HttpHeader.TUS_RESUMABLE, TusFileUploadService.TUS_API_VERSION); + patch2.addHeader(HttpHeader.UPLOAD_OFFSET, "10"); + patch2.addHeader(HttpHeader.CONTENT_TYPE, "application/offset+octet-stream"); + patch2.setContent("abcdefghij".getBytes(StandardCharsets.UTF_8)); + MockHttpServletResponse patchResponse2 = new MockHttpServletResponse(); + UploadInfo patchInfo2 = tusFileUploadService.process(patch2, patchResponse2); + assertNotNull(patchInfo2); + assertEquals(1, listenerCallCount.get()); + + // 5. Send subsequent HEAD request on completed upload - must NOT trigger listener again + MockHttpServletRequest headAfterComplete = new MockHttpServletRequest("HEAD", location); + headAfterComplete.addHeader(HttpHeader.TUS_RESUMABLE, TusFileUploadService.TUS_API_VERSION); + MockHttpServletResponse headAfterResponse = new MockHttpServletResponse(); + UploadInfo headAfterInfo = tusFileUploadService.process(headAfterComplete, headAfterResponse); + assertNotNull(headAfterInfo); + assertEquals(1, listenerCallCount.get()); + } + + @Test(expected = IOException.class) + public void testCheckWasInProgressWhenStorageThrows() throws Exception { + UploadStorageService mockStorage = mock(UploadStorageService.class); + when(mockStorage.getUploadUri()).thenReturn("/files"); + when(mockStorage.getUploadInfo(anyString(), any())) + .thenThrow(new IOException("Storage failure")); + + TusFileUploadService service = new TusFileUploadService().withUploadStorageService(mockStorage); + MockHttpServletRequest patchRequest = new MockHttpServletRequest("PATCH", "/files/some-id"); + + // Should catch exception gracefully and not propagate + UploadInfo info = service.process(patchRequest, new MockHttpServletResponse()); + assertNull(info); + } +}