diff --git a/.gitignore b/.gitignore index 1ca42da7..35ef616a 100644 --- a/.gitignore +++ b/.gitignore @@ -172,3 +172,4 @@ buildNumber.properties __pycache__/ .venv/ *.pyc +CONFORMITY_TEST_IMPROVEMENTS.md diff --git a/AGENTS.md b/AGENTS.md index 4b25b658..e69541b7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,6 +70,7 @@ Completed parent uploads are indexed by checksum under the `/checks ### 10. Unit Test Coverage & Pragmatic Testing - Unit test coverage must remain high for all new feature logic, handlers, validators, and core workflows. +- **Mandatory Test Addition Rule**: Whenever any functional change, feature implementation, or protocol fix is added, corresponding unit tests MUST ALWAYS be added automatically to prove the fix/feature. Compliance unit tests MUST contain section references and verbatim specification quotes in method Javadocs based on the official specification. - Do not use reflection to test private helper methods. Always test code through public API boundaries instead of bypassing encapsulation. - Compliance unit tests in `me.desair.tus.server.rufh` MUST contain verbatim specification quotes in method Javadocs based on the official specification. - Coverage should focus on meaningful domain logic and contract behavior. Do not over-complicate test suites, write brittle reflection hacks, or add unnatural code structures solely to hit 100% JaCoCo coverage on defensive catch blocks or trivial fallbacks. @@ -109,6 +110,12 @@ Whenever a new setter or configuration property (such as `setMinAppendSize`, `se - `TusFileUploadService.withUploadStorageService(...)` MUST be updated to copy the setting from the old `UploadStorageService` instance to the new one. - `ThreadLocalCachedStorageAndLockingService` MUST delegate the setter and getter methods to `storageServiceDelegate`. +### 15. Typed Exceptions & HttpServletResponse Status Codes +- Do NOT throw generic `TusException` directly when throwing protocol errors or request validation failures. +- Always throw specific typed exceptions from the `me.desair.tus.server.exception` package (e.g., `UploadNotFoundException`, `InvalidUploadMetadataException`, `UploadLengthExceededException`, `InvalidHttpDigestException`). +- If a new error condition is introduced, create a new typed exception class in `me.desair.tus.server.exception` that extends `TusException`. +- Typed exception constructors MUST use `jakarta.servlet.http.HttpServletResponse` HTTP status code constants (e.g., `HttpServletResponse.SC_BAD_REQUEST`, `HttpServletResponse.SC_CONFLICT`, `HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE`) when calling `super(status, message)`. + ## IETF Resumable Uploads for HTTP (RUFH) Spec Maintenance & Update Playbook ### 1. Spec Diff Review @@ -135,3 +142,64 @@ When updating the IETF protocol implementation for a new draft revision, follow ```bash mvn verify -Pcheck-coverage -Djacoco.compare.branch=master -q ``` + +### 4. Conformity Test Suite Maintenance & Subagent Isolation +Whenever a new draft revision of the RUFH specification is published, the repository's Python conformity test suite (`scripts/rufh_conformity_test.py`) MUST be reviewed and updated by a separate, dedicated subagent. +- **Strict Isolation Rule**: The subagent tasked with updating `scripts/rufh_conformity_test.py` MUST ONLY consult the official IETF specification document (and RFC 9530) and MUST NOT inspect the Java server implementation code under `src/main/java/`. This ensures the conformity test suite remains an independent, unbiased specification benchmark. + +### 5. Conformity Test Suite Audit — Repeatable Procedure +Use this procedure to audit `scripts/rufh_conformity_test.py` against the current (or a new) specification revision. The goal is to identify untested MUST/SHOULD/MAY requirements and produce an actionable improvement report. + +#### 5.1 Inputs +- **Specification document**: The full text of the target draft revision, e.g.: + `https://www.ietf.org/archive/id/draft-ietf-httpbis-resumable-upload-.txt` +- **Test suite**: `scripts/rufh_conformity_test.py` (read it in full). +- **Previous audit report** (if any): `CONFORMITY_TEST_IMPROVEMENTS.md` in the project root. + +#### 5.2 Isolation Rules +- **Do NOT read any Java source code** under `src/main/java/` during the audit. The audit must be purely spec-vs-test-script. +- The only project files to read are `scripts/rufh_conformity_test.py` and optionally `CONFORMITY_TEST_IMPROVEMENTS.md`. +- You may read the specification document, RFC 9530 (HTTP Digests), RFC 9651 (Structured Fields), and RFC 9457 (Problem Details) for normative context. + +#### 5.3 Audit Methodology (Clause-by-Clause) +Walk through every normative section of the specification in order. For each section: + +1. **Extract every requirement** containing MUST, MUST NOT, SHOULD, SHOULD NOT, or MAY (per RFC 2119 / RFC 8174 semantics). +2. **For each requirement**, search the test suite for a test that exercises it: + - Check if the test sends the right request (method, headers, body). + - Check if the test asserts the correct response behavior (status code, headers, body content). + - Note whether the test covers both the positive (conformant) and negative (non-conformant input) cases. +3. **Classify the finding**: + - ✅ **Covered** — a test exists and its assertions match the requirement. + - ✅ **Partial** — a test exists but assertions are incomplete or only cover one case. + - ❌ **Missing** — no test covers this requirement. +4. **For partial/missing items**, write a concrete recommendation: test method name, spec section, request/response to send, and assertions to make. + +The sections to audit (for draft-12) are: +- §4.1.1 (Offset), §4.1.2 (Completeness), §4.1.3 (Length), §4.1.4 (Limits) +- §4.2 (Upload Creation): §4.2.1 (Client Behavior), §4.2.2 (Server Behavior) +- §4.3 (Offset Retrieval): §4.3.1 (Client Behavior), §4.3.2 (Server Behavior) +- §4.4 (Upload Append): §4.4.1 (Client Behavior), §4.4.2 (Server Behavior) +- §4.5 (Upload Cancellation): §4.5.1, §4.5.2 (Server Behavior) +- §4.6 (Concurrency), §4.7 (Retry) +- §5 (Status Code 104) +- §6 (Media Type application/partial-upload) +- §7.1 (Mismatching Offset problem type), §7.2 (Inconsistent Length problem type) +- §10.1 (Optimistic Upload Creation), §10.1.1 (Upgrading), §10.2 (Careful Upload Creation) + +#### 5.4 Output Format +Produce a Markdown report saved as `CONFORMITY_TEST_IMPROVEMENTS.md` in the project root (overwrite the previous version). The report MUST contain: + +1. **Executive Summary** — overall coverage assessment. +2. **Critical Gaps** (🔴) — untested MUST-level requirements, with spec quotes and recommended test methods. +3. **Important Gaps** (🟡) — untested SHOULD-level requirements or incomplete assertions. +4. **Minor Improvements** (🔵) — edge cases, test quality improvements, spec alignment. +5. **Existing Test Corrections** — any tests with incorrect or overly permissive assertions. +6. **Recommended New Test Methods** — organized by test class, with spec section, method name, and description. +7. **Summary Matrix** — table with columns: Spec Section, Requirement Level, Currently Tested (✅/✅ Partial/❌), Gap Description. + +#### 5.5 How to Invoke This Audit +Request the audit with a prompt like: +> Perform a strict conformity audit of `scripts/rufh_conformity_test.py` against the draft-12 specification at `https://www.ietf.org/archive/id/draft-ietf-httpbis-resumable-upload-12.txt`. Follow the audit procedure in AGENTS.md §5. Do NOT inspect any Java implementation code. + +To audit against a newer draft, replace the draft number in the URL. diff --git a/CHANGELOG.md b/CHANGELOG.md index c78bbe57..bacc9d46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,13 +5,16 @@ All notable changes to this project will be documented in this file. ## [2.0.0] ### Added -- **IETF Resumable Uploads for HTTP (RUFH) Protocol Compliance**: Implemented full support for the official IETF Resumable Uploads for HTTP specification (`draft-ietf-httpbis-resumable-upload`). +- **IETF Resumable Uploads for HTTP (RUFH) Protocol**: Implemented full support for the official IETF Resumable Uploads for HTTP specification (`draft-ietf-httpbis-resumable-upload-12`). - **Dual Protocol Auto-Detection**: Added transparent protocol routing in `TusFileUploadService` supporting both legacy `TUS_1_0_0` (`Tus-Resumable: 1.0.0`) and `RUFH` (`ProtocolVersion.RUFH`) clients concurrently on the same endpoint. - **RFC 9651 Structured Header Fields**: Implemented RFC 9651 parsing and serialization for `Upload-Offset`, `Upload-Complete`, `Upload-Length`, and `Upload-Limit` dictionary headers. - **RFC 7807 Problem Details JSON**: Added support for standard `application/problem+json` error responses (`mismatching-upload-offset`, `completed-upload`, `inconsistent-upload-length`). -- **Dedicated Compliance & Security Test Suites**: Added comprehensive, spec-quoted unit tests under package `me.desair.tus.server.ietf` and security tests under `me.desair.tus.server.ietf.security` verifying Path Traversal protection, DoS limits, CRLF sanitization, and lock safety. +- **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. +### Breaking +- **Downloads**: In order to support both the Tus protocol and RUFH protocol, the unofficial download extension will not return a HTTP status code `204` for uploads that are still in progress and will not contain the response header `Tus-Resumable`. Removed the `UploadInProgressException` class. + ## [1.0.0-3.3] ### Added diff --git a/README.md b/README.md index 790359a5..80ef5714 100644 --- a/README.md +++ b/README.md @@ -86,8 +86,8 @@ The first step is to create a `TusFileUploadService` object using its constructo * `withChunkedTransferDecoding`: You can enable or disable the decoding of chunked HTTP requests by this library. Enable this feature in case the web container in which this service is running does not decode chunked transfers itself. By default, chunked decoding via this library is disabled (as modern frameworks tend to already do this for you). * `withThreadLocalCache(Boolean)`: Optionally you can enable (or disable) an in-memory (thread local) cache of upload request data to reduce load on the storage backend and potentially increase performance when processing upload requests. * `withUploadExpirationPeriod(Long)`: You can set the number of milliseconds after which an upload is considered as expired and available for cleanup. Applies to both Tus 1.0.0 (`Upload-Expires` response header) and IETF RUFH (`max-age` parameter in `Upload-Limit` response header). -* `getRawInterimResponse(HttpServletRequest, String)`: Helper method that inspects an incoming request and returns the raw HTTP 104 interim response frame string (`HTTP/1.1 104 Upload Resumption Supported\r\nLocation: ...\r\nUpload-Offset: 0\r\n\r\n`) if applicable, or `null` otherwise. Useful for web container extensions (such as Tomcat Valves) that flush 1xx interim responses directly to client sockets. -* `withDownloadFeature()`: Enable the unofficial `download` extension that also allows you to download uploaded bytes. +* `withDownloadFeature()`: Enable the unofficial `download` extension that allows clients to download uploaded bytes via `GET`. This feature is disabled by default. + * **Disclaimer**: Enabling the download extension for `GET` requests may interfere with IETF RUFH `GET` offset retrieval conformity (Section 4.3 of draft-12), as RUFH specifies `GET` requests for offset retrieval returning `204 No Content`. * `withUploadDeduplication(Boolean)`: Enable duplicate file processing based on the checksum hash. If enabled, the server will scan previous completed uploads for a file with the same checksum. If a duplicate is found, the new upload will link to the existing file (`duplicatesUploadId`), skipping redundant disk storage writes and saving disk space. * **Disclaimer**: If duplicate file processing is enabled, the duplicate (child) upload depends directly on the original (parent) upload file. If the original parent upload is deleted or terminated, any duplicate child uploads pointing to it will no longer be downloadable (returning `404 Not Found`). * `addTusExtension(TusExtension)`: Add a custom (application-specific) extension that implements the `me.desair.tus.server.TusExtension` interface. For example you can add your own extension that checks authentication and authorization policies within your application for the user doing the upload. @@ -135,10 +135,12 @@ After having processed the uploaded bytes on the server backend (e.g. copy them Next to removing uploads after they have been completed and processed by the backend, it is also recommended to schedule a regular maintenance task to clean up any expired uploads or locks. Cleaning up expired uploads and locks can be achieved using the `me.desair.tus.server.TusFileUploadService.cleanup()` method. -## Compatible Client Implementations +## Compatible Client Implementations & Conformity Testing This server implementation has been tested with: - **Tus 1.0.0 Clients**: Tested with [Uppy](https://uppy.io/) and `tus-js-client`. -- **IETF Resumable Uploads Clients**: For now, the implementation has only been tested with the [RUFH conformity tests of the IETF hackathon](https://github.com/tus/ietf-hackathon). +- **IETF Resumable Uploads Clients & Conformity Tests**: The implementation has been thoroughly tested with our own built-in RUFH conformity test suite (`scripts/rufh_conformity_test.py`) validating compliance with draft-12 of the RUFH protocol specification and RFC 9530 HTTP Digests, as well as the community [RUFH conformity tests from the IETF hackathon](https://github.com/tus/ietf-hackathon). + +For detailed instructions on running our native conformity test suite and interpreting results, see the **[Conformity Testing Guide (docs/CONFORMITY_TESTING.md)](docs/CONFORMITY_TESTING.md)**. This repository also contains comprehensive automated integration test suites (`ITTusFileUploadService`, `IetfProtocolCreationTest`, `IetfProtocolAppendTest`, `IetfProtocolHeadTest`, `IetfProtocolCancellationTest`) validating both protocol specifications. diff --git a/docs/CONFORMITY_TESTING.md b/docs/CONFORMITY_TESTING.md index 617a5653..0255a0d8 100644 --- a/docs/CONFORMITY_TESTING.md +++ b/docs/CONFORMITY_TESTING.md @@ -1,6 +1,8 @@ # Conformity Testing Guide (IETF Resumable Uploads for HTTP) -This guide describes how to manually execute the RUFH conformity tests against a locally running instance of the Spring Boot demo server using the community conformity testing suite. +This guide describes how to execute the RUFH (Resumable Uploads for HTTP) conformity tests against a locally running instance of the Spring Boot demo server or any RUFH compliant server endpoint. + +The test suite validates compliance with [draft-ietf-httpbis-resumable-upload-12](https://www.ietf.org/archive/id/draft-ietf-httpbis-resumable-upload-12.txt) and [RFC 9530 HTTP Digests](https://www.rfc-editor.org/rfc/rfc9530.html). --- @@ -10,14 +12,14 @@ First, compile and install the core `tus-java-server` library to your local Mave ```bash # In the root of the tus-java-server repository -mvn clean install +mvn clean install -DskipTests ``` --- ## 2. Start the Demo Server -1. Update the dependency version in the demo project if necessary. In `tus-java-server-spring-demo` project's `spring-boot-rest/pom.xml`, verify it points to the locally built snapshot version: +1. Verify the dependency in `tus-java-server-spring-demo` project's `spring-boot-rest/pom.xml` points to the snapshot version: ```xml me.desair.tus @@ -26,11 +28,11 @@ mvn clean install ``` -2. Build and start the Spring Boot REST demo server: +2. Build and start the Spring Boot REST demo server with a `1 KB` maximum upload size parameter (`--tus.server.max-upload-size=1024`) to enable full limit discovery & limit enforcement verification: ```bash cd ../tus-java-server-spring-demo - mvn clean package - java -jar spring-boot-rest/target/spring-boot-rest-0.0.1-SNAPSHOT.jar + mvn clean package -DskipTests + java -jar spring-boot-rest/target/spring-boot-rest-0.0.1-SNAPSHOT.jar --tus.server.max-upload-size=1024 ``` The server will start on port `8080` with the upload endpoint exposed at: @@ -38,30 +40,52 @@ mvn clean install --- -## 3. Clone and Run the Conformity Tester +## 3. Run the Built-In RUFH Conformity Test Suite -The conformity tests are written in Python using `pytest` and are maintained by the community under the `ietf-hackathon` repository. +The repository includes its own native Python conformity test suite located at `scripts/rufh_conformity_test.py`. It requires `pytest` and `requests`. -1. Clone the repository and navigate to the tests directory: - ```bash - git clone https://github.com/tus/ietf-hackathon.git - cd ietf-hackathon/tests - ``` +### Prerequisites +Install Python dependencies if not already installed: +```bash +pip install pytest requests +``` -2. Set up a Python virtual environment and activate it: - ```bash - python3 -m venv venv - source venv/bin/activate - ``` +### Running the Test Suite -3. Install required Python packages: - ```bash - pip install -r requirements.txt - ``` +You can execute the test suite using Python directly or via PyTest: -4. Run the conformity tests pointing to your locally running Spring Boot endpoint: - ```bash - pytest --url http://localhost:8080/test/api/upload - ``` +#### Option A: Running directly with Python (Recommended for structured AI / Agent reporting) +```bash +python3 scripts/rufh_conformity_test.py --url http://localhost:8080/test/api/upload +``` + +#### Option B: Running with PyTest +```bash +pytest scripts/rufh_conformity_test.py --url http://localhost:8080/test/api/upload +``` + +--- + +## 4. Understanding Test Results & AI Agent Remediation + +When executed, the script produces a structured summary report detailing: + +1. **Total Tests Executed**: Count of total specification compliance tests run. +2. **Passed Tests**: Number of tests matching draft-12 specification requirements. +3. **Failed Tests**: Detailed list of failing tests including test method names, exact error tracebacks, expected status codes/headers, and corresponding RFC section references. +4. **104 Interim Responses**: Count of tests where `HTTP/1.1 104 Upload Resumption Supported` interim responses were detected from the server socket. - All tests should pass, certifying that the server implementation conforms to the IETF Resumable Uploads for HTTP (RUFH) specification. +AI agents and developers can analyze the detailed failure breakdown in the script's console output to pinpoint specific compliance gaps and adjust server logic accordingly. + +--- + +## 5. Running Community (IETF Hackathon) Tests + +Alternatively, you can also run the external community test suite from the `ietf-hackathon` repository: + +```bash +git clone https://github.com/tus/ietf-hackathon.git +cd ietf-hackathon/tests +pip install -r requirements.txt +pytest --url http://localhost:8080/test/api/upload +``` diff --git a/docs/INTERIM_RESPONSES.md b/docs/INTERIM_RESPONSES.md index c3ee3a6e..9c6e83d0 100644 --- a/docs/INTERIM_RESPONSES.md +++ b/docs/INTERIM_RESPONSES.md @@ -87,7 +87,7 @@ public class TusInterimResponseTomcatValve extends ValveBase { // Step 2: Write raw HTTP 104 bytes directly to Tomcat's underlying SocketWrapperBase boolean written = writeToSocketWrapper(response, bytes); if (written) { - LOG.info( + LOG.debug( "Emitted raw HTTP 104 Interim Response via Tomcat SocketWrapper for request URI: {}", request.getRequestURI()); } else { diff --git a/pom.xml b/pom.xml index 45451962..9509d35f 100644 --- a/pom.xml +++ b/pom.xml @@ -187,6 +187,10 @@ error + plain + + true + @@ -202,6 +206,10 @@ error + plain + + true + diff --git a/scripts/conftest.py b/scripts/conftest.py new file mode 100644 index 00000000..38bdc5c8 --- /dev/null +++ b/scripts/conftest.py @@ -0,0 +1,11 @@ +import os +import pytest + +def pytest_addoption(parser): + """Register --url CLI option for pytest.""" + parser.addoption( + "--url", + action="store", + default=os.environ.get("RUFH_URL", "http://localhost:8080/test/api/upload"), + help="Target RUFH upload endpoint URL", + ) diff --git a/scripts/release.py b/scripts/release.py index ecf2fd1d..f5aa002c 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -7,7 +7,7 @@ import shutil import xml.etree.ElementTree as ET -# ponytail: simple release automation script using stdlib to minimize dependencies. +# Simple release automation script using stdlib to minimize dependencies. # The script supports validation (dry-run + snapshot deploy) and actual release. LOG_FILE = "release.log" diff --git a/scripts/rufh_conformity_test.py b/scripts/rufh_conformity_test.py new file mode 100755 index 00000000..6e4c0319 --- /dev/null +++ b/scripts/rufh_conformity_test.py @@ -0,0 +1,1227 @@ +#!/usr/bin/env python3 +""" +RUFH (Resumable Uploads for HTTP) Draft-12 & RFC 9530 Conformity Test Suite + +This conformity test suite validates a server implementation against the +IETF Resumable Uploads for HTTP draft-12 specification: + https://www.ietf.org/archive/id/draft-ietf-httpbis-resumable-upload-12.txt +and RFC 9530 HTTP Digests: + https://www.rfc-editor.org/rfc/rfc9530.html + +Usage: + pytest scripts/rufh_conformity_test.py --url http://localhost:8080/test/api/upload + python3 scripts/rufh_conformity_test.py --url http://localhost:8080/test/api/upload +""" + +import argparse +import base64 +import hashlib +import json +import os +import socket +import sys +import threading +import time +from urllib.parse import urlparse + +import pytest + +# Protocol Constants +TRUE = '?1' +FALSE = '?0' +UPLOAD_COMPLETE = 'Upload-Complete' +UPLOAD_OFFSET = 'Upload-Offset' +UPLOAD_LENGTH = 'Upload-Length' +UPLOAD_LIMIT = 'Upload-Limit' +LOCATION = 'Location' +CONTENT_TYPE = 'Content-Type' +APPLICATION_PARTIAL_UPLOAD = 'application/partial-upload' +APPLICATION_PROBLEM_JSON = 'application/problem+json' + +# Global set to track tests where 104 interim responses were detected +INTERIM_RESPONSES_DETECTED = set() + + +def pytest_addoption(parser): + """Add command line options to pytest.""" + parser.addoption( + "--url", + action="store", + default="http://localhost:8080/test/api/upload", + help="Target RUFH upload endpoint URL" + ) + + +@pytest.fixture(scope="session") +def target_url(request): + """Fixture providing the target upload URL.""" + try: + return request.config.getoption("--url") + except (ValueError, AttributeError): + return os.environ.get("RUFH_URL", "http://localhost:8080/test/api/upload") + + +class CaseInsensitiveDict(dict): + """A case-insensitive dictionary for HTTP headers.""" + def __init__(self, data=None, **kwargs): + super().__init__() + self._keys = {} + if data: + self.update(data) + if kwargs: + self.update(kwargs) + + def __setitem__(self, key, value): + super().__setitem__(key.lower(), value) + self._keys[key.lower()] = key + + def __getitem__(self, key): + return super().__getitem__(key.lower()) + + def __delitem__(self, key): + super().__delitem__(key.lower()) + del self._keys[key.lower()] + + def __contains__(self, key): + return super().__contains__(key.lower()) + + def get(self, key, default=None): + return super().get(key.lower(), default) + + def update(self, other=None, **kwargs): + if hasattr(other, "items"): + for k, v in other.items(): + self[k] = v + elif other: + for k, v in other: + self[k] = v + for k, v in kwargs.items(): + self[k] = v + + def items(self): + return ((self._keys[k], v) for k, v in super().items()) + + +def parse_headers(lines): + res_headers = CaseInsensitiveDict() + for line in lines: + if ":" in line: + k, v = line.split(":", 1) + res_headers[k.strip()] = v.strip() + return res_headers + + +def http_request(method, url, headers=None, body=None, test_name=""): + """ + Socket-based HTTP client helper that transparently handles HTTP 104 interim response frames. + Tracks 104 interim response detection in INTERIM_RESPONSES_DETECTED. + Returns (status_code, headers_dict, body_bytes, interim_104_headers_list). + """ + if headers is None: + headers = {} + parsed = urlparse(url) + host = parsed.hostname or "localhost" + port = parsed.port or 80 + path = parsed.path + ("?" + parsed.query if parsed.query else "") + + s = socket.create_connection((host, port), timeout=5) + try: + req_headers = CaseInsensitiveDict(headers) + if "Connection" not in req_headers: + req_headers["Connection"] = "close" + + req_lines = [f"{method} {path} HTTP/1.1", f"Host: {host}:{port}"] + for k, v in req_headers.items(): + req_lines.append(f"{k}: {v}") + if body is not None and "Content-Length" not in req_headers: + body_len = len(body) if isinstance(body, bytes) else len(body.encode("utf-8")) + req_lines.append(f"Content-Length: {body_len}") + req_lines.append("") + req_lines.append("") + req_data = "\r\n".join(req_lines).encode("latin1") + if body: + req_data += body if isinstance(body, bytes) else body.encode("utf-8") + + s.sendall(req_data) + + # Read response data + resp_bytes = b"" + while True: + chunk = s.recv(4096) + if not chunk: + break + resp_bytes += chunk + + raw_str = resp_bytes.decode("latin1", errors="replace") + parts = raw_str.split("\r\n\r\n") + + interim_104_headers = [] + final_headers_part = "" + final_body_part = "" + + for idx, part in enumerate(parts): + if part.startswith("HTTP/1."): + lines = part.split("\r\n") + status_line = lines[0] + status_code = int(status_line.split()[1]) if len(status_line.split()) > 1 else 0 + if status_code == 104: + interim_104_headers.append(parse_headers(lines[1:])) + else: + final_headers_part = part + final_body_part = "\r\n\r\n".join(parts[idx + 1 :]) + break + + if not final_headers_part and parts: + final_headers_part = parts[0] + + if interim_104_headers and test_name: + INTERIM_RESPONSES_DETECTED.add(test_name) + if "__main__" in sys.modules and hasattr(sys.modules["__main__"], "INTERIM_RESPONSES_DETECTED"): + sys.modules["__main__"].INTERIM_RESPONSES_DETECTED.add(test_name) + + lines = final_headers_part.split("\r\n") + status_line = lines[0] + status_code = int(status_line.split()[1]) if len(status_line.split()) > 1 else 0 + + res_headers = parse_headers(lines[1:]) + return status_code, res_headers, final_body_part.encode("latin1"), interim_104_headers + except (socket.timeout, ConnectionRefusedError, socket.error) as e: + pytest.fail(f"HTTP request failed: {e}") + finally: + s.close() + + +def create_partial_upload(target_url, test_name="", upload_length="100"): + """Helper to create a partial upload returning absolute Location URI.""" + payload = b"" + headers = { + UPLOAD_COMPLETE: FALSE, + } + if upload_length is not None: + headers[UPLOAD_LENGTH] = str(upload_length) + status, headers_dict, _, interim = http_request("POST", target_url, headers=headers, body=payload, test_name=test_name) + assert status == 201, f"Expected 201 Created for upload creation, got {status}" + + # Validation for §5 (104 interim responses during creation MUST include Location) + if interim: + for i_headers in interim: + assert LOCATION in i_headers, "104 interim response during creation MUST include Location" + + loc = headers_dict.get(LOCATION) + assert loc, "Location header MUST be returned upon 201 Created" + if not loc.startswith("http"): + parsed = urlparse(target_url) + loc = f"{parsed.scheme}://{parsed.netloc}{loc}" + return loc + + +class TestUploadState: + """Tests for Upload State Constraints (§4.1).""" + + def test_append_non_integer_upload_offset(self, target_url, request): + """ + §4.1.1: Upload-Offset with non-integer value MUST be ignored. + Quote: "If the Upload-Offset header field is present and its value is not a valid integer, the server MUST ignore it." + Expected behavior: The server should reject the request since without a valid Upload-Offset, the append is invalid. + """ + upload_uri = create_partial_upload(target_url, test_name=request.node.name) + headers = { + UPLOAD_OFFSET: "abc", + UPLOAD_COMPLETE: FALSE, + CONTENT_TYPE: APPLICATION_PARTIAL_UPLOAD, + } + status, _, _, _ = http_request("PATCH", upload_uri, headers=headers, body=b"A" * 32, test_name=request.node.name) + assert status in (400, 409), f"Non-integer Upload-Offset must be rejected, got {status}" + + def test_offset_never_decreases(self, target_url, request): + """ + §4.1.1: Offset MUST NOT decrease after data is processed. + Quote: "If the server loses any part of the state, it MUST deactivate the upload resource and reject further interaction with it." + Expected behavior: Decreasing offset must be rejected (e.g. 409). Furthermore, a subsequent HEAD should confirm the resource is deactivated (404/410). + """ + upload_uri = create_partial_upload(target_url, test_name=request.node.name) + + # Append some data + http_request("PATCH", upload_uri, + headers={UPLOAD_OFFSET: "0", UPLOAD_COMPLETE: FALSE, CONTENT_TYPE: APPLICATION_PARTIAL_UPLOAD}, + body=b"A" * 32, test_name=request.node.name) + + # Retrieve offset + _, h_headers, _, _ = http_request("HEAD", upload_uri, test_name=request.node.name) + assert int(h_headers.get(UPLOAD_OFFSET, "0")) >= 32 + + # Attempt to append at a lower offset + status, resp_headers, _, _ = http_request("PATCH", upload_uri, + headers={UPLOAD_OFFSET: "5", UPLOAD_COMPLETE: FALSE, CONTENT_TYPE: APPLICATION_PARTIAL_UPLOAD}, + body=b"B" * 32, test_name=request.node.name) + assert status == 409, f"Mismatching/stale offset MUST be rejected with 409 Conflict, got {status}" + assert resp_headers.get(UPLOAD_OFFSET) == "32", "409 response MUST include current server Upload-Offset for client resumption" + + # Verify resource remains valid for resumption (Section 4.4.2) + h_status, h_headers, _, _ = http_request("HEAD", upload_uri, test_name=request.node.name) + assert h_status in (200, 204), "Upload resource MUST remain valid for client resumption after 409 offset mismatch" + assert h_headers.get(UPLOAD_OFFSET) == "32", "HEAD response MUST return correct server offset" + + def test_creation_invalid_boolean_upload_complete(self, target_url, request): + """ + §4.1.2: Upload-Complete with invalid Boolean value MUST be ignored. + Quote: "Other values MUST cause the entire header field to be ignored." + Expected behavior: The request becomes a regular POST if the header is ignored. It should process based on target resource's normal POST behavior. + """ + headers = {UPLOAD_COMPLETE: "true", UPLOAD_LENGTH: "10"} + status, _, _, _ = http_request("POST", target_url, headers=headers, body=b"Hello", test_name=request.node.name) + assert status in (400, 404, 405, 200, 201), f"Expected rejection or normal non-resumable POST behavior, got {status}" + + def test_unknown_length_upload(self, target_url, request): + """ + §4.1.3: Upload-Length without Upload-Complete (unknown length scenario). + Quote: "If the request does not include the Upload-Length header field, the representation's length is unknown." + Expected behavior: Upload-Length shouldn't be present in HEAD response. After complete append, it should be set. + """ + upload_uri = create_partial_upload(target_url, test_name=request.node.name, upload_length=None) + + # Initial offset retrieval + status, resp_headers, _, _ = http_request("HEAD", upload_uri, test_name=request.node.name) + assert status in (204, 200) + assert UPLOAD_LENGTH not in resp_headers, "Upload-Length should not be present when unknown" + + # Completing append + status, _, _, _ = http_request("PATCH", upload_uri, + headers={UPLOAD_OFFSET: "0", UPLOAD_COMPLETE: TRUE, CONTENT_TYPE: APPLICATION_PARTIAL_UPLOAD}, + body=b"A" * 32, test_name=request.node.name) + assert status in (200, 204, 201) + + # Retrieve final offset and length + status, resp_headers, _, _ = http_request("HEAD", upload_uri, test_name=request.node.name) + assert resp_headers.get(UPLOAD_LENGTH) == "32", "Upload-Length must be known after completion" + + +class TestUploadCreation: + """Tests for Upload Creation (Section 4.2).""" + + def test_creation_optimistic_complete_upload(self, target_url, request): + """ + §4.2: Optimistic complete upload with Upload-Complete: ?1. + Quote: "The server SHOULD NOT generate a response with the 301, 302, or 303 status codes..." + Expected behavior: Should return 200/201 and Upload-Complete ?1. Must not redirect. + """ + payload = b"Hello, RUFH World!" + headers = { + UPLOAD_COMPLETE: TRUE, + UPLOAD_LENGTH: str(len(payload)), + CONTENT_TYPE: "text/plain", + } + status, resp_headers, _, _ = http_request("POST", target_url, headers=headers, body=payload, test_name=request.node.name) + assert status in (200, 201), f"Expected status 200 or 201, got {status}" + assert resp_headers.get(UPLOAD_COMPLETE) == TRUE, f"Expected Upload-Complete: ?1" + assert status not in (301, 302, 303), "Server SHOULD NOT generate 301/302/303 redirect" + + def test_creation_partial_upload(self, target_url, request): + """ + §4.2: Upload creation with partial representation. + Quote: "the server MUST include the Location response header field pointing to the upload resource and MUST include the Upload-Limit header field" + Expected behavior: 201 Created with Location and Upload-Limit. Upload-Offset should be present. + """ + payload = b"X" * 32 + headers = { + UPLOAD_COMPLETE: FALSE, + UPLOAD_LENGTH: "100", + CONTENT_TYPE: "text/plain", + } + status, resp_headers, _, _ = http_request("POST", target_url, headers=headers, body=payload, test_name=request.node.name) + assert status == 201, f"Expected 201 Created for partial upload creation, got {status}" + assert resp_headers.get(LOCATION), "Response MUST include Location header" + assert resp_headers.get(UPLOAD_COMPLETE) == FALSE, f"Expected Upload-Complete: ?0" + assert status not in (301, 302, 303), "Server SHOULD NOT generate 301/302/303 redirect" + assert resp_headers.get(UPLOAD_OFFSET), "Upload-Offset MUST be returned in partial creation response" + assert UPLOAD_LIMIT in resp_headers, "Upload-Limit MUST be included in upload creation response" + + def test_creation_empty_upload(self, target_url, request): + """ + §4.2.1: Upload creation with empty body and Upload-Complete: ?0. + Quote: "the server MUST include the Location response header field pointing to the upload resource and MUST include the Upload-Limit header field" + Expected behavior: 201 Created with Location. Upload-Limit MUST be present (Important Gap 2.6). + """ + headers = {UPLOAD_COMPLETE: FALSE, UPLOAD_LENGTH: "100"} + status, resp_headers, _, _ = http_request("POST", target_url, headers=headers, body=b"", test_name=request.node.name) + assert status == 201 + assert resp_headers.get(LOCATION) + assert resp_headers.get(UPLOAD_COMPLETE) == FALSE + assert UPLOAD_LIMIT in resp_headers, "Upload-Limit MUST be included in empty creation response" + + def test_creation_empty_representation(self, target_url, request): + """ + §4.2.1: Empty body with Upload-Complete: ?1 uploads empty representation. + Quote: "A client MAY create a resumable upload resource without uploading any data..." + Expected behavior: 200/201 with Upload-Complete ?1. + """ + headers = {UPLOAD_COMPLETE: TRUE, UPLOAD_LENGTH: "0"} + status, resp_headers, _, _ = http_request("POST", target_url, headers=headers, body=b"", test_name=request.node.name) + assert status in (200, 201) + assert resp_headers.get(UPLOAD_COMPLETE) == TRUE + + def test_creation_content_disposition(self, target_url, request): + """ + §4.2.1: Content-Disposition header acceptance. + Quote: "For this purpose, the inline disposition type is RECOMMENDED." + Expected behavior: Content-Disposition: inline should be accepted. + """ + headers = {UPLOAD_COMPLETE: FALSE, UPLOAD_LENGTH: "100", "Content-Disposition": 'inline; filename="test.txt"'} + status, _, _, _ = http_request("POST", target_url, headers=headers, body=b"X" * 32, test_name=request.node.name) + assert status == 201 + + def test_creation_inconsistent_length(self, target_url, request): + """ + §4.1.3 & 7.2: Inconsistent Upload-Length. + Quote: "The server MUST reject a request if the representation's length is known and inconsistent..." + Expected behavior: 400 error. Optionally check Upload-Offset if server responded gracefully. + """ + headers = {UPLOAD_COMPLETE: TRUE, UPLOAD_LENGTH: "100"} + status, resp_headers, body, _ = http_request("POST", target_url, headers=headers, body=b"12345", test_name=request.node.name) + assert status == 400 + if resp_headers.get("Content-Type", "").startswith(APPLICATION_PROBLEM_JSON): + prob = json.loads(body.decode("utf-8")) + assert prob.get("type") == "https://iana.org/assignments/http-problem-types#inconsistent-upload-length" + assert resp_headers.get(UPLOAD_COMPLETE) == FALSE + # Optional check for Upload-Offset (Correction 4.3) + if UPLOAD_OFFSET in resp_headers: + assert int(resp_headers.get(UPLOAD_OFFSET)) >= 0 + + def test_creation_inconsistent_length_across_requests(self, target_url, request): + """ + §4.1.3 & 7.2: Length MUST stay consistent across requests. + Quote: "The server MUST reject a request if the representation's length is known and inconsistent..." + Expected behavior: PATCH with mismatched Upload-Length must be rejected with 400. + """ + upload_uri = create_partial_upload(target_url, test_name=request.node.name, upload_length="100") + headers = { + UPLOAD_OFFSET: "0", + UPLOAD_COMPLETE: FALSE, + UPLOAD_LENGTH: "50", + CONTENT_TYPE: APPLICATION_PARTIAL_UPLOAD, + } + status, resp_headers, body, _ = http_request("PATCH", upload_uri, headers=headers, body=b"A" * 32, test_name=request.node.name) + assert status == 400 + if resp_headers.get("Content-Type", "").startswith(APPLICATION_PROBLEM_JSON): + prob = json.loads(body.decode("utf-8")) + assert prob.get("type") == "https://iana.org/assignments/http-problem-types#inconsistent-upload-length" + assert resp_headers.get(UPLOAD_COMPLETE) == FALSE + + def test_creation_location_consistent_across_interim_and_final(self, target_url, request): + """ + §4.2.2: Location MUST Be Identical Across Interim and Final Responses. + Quote: "all interim and final response messages for the same request MUST contain an identical Location value" + Expected behavior: Interim 104 responses during creation must have identical Location headers to final 201 response. + """ + payload = b"" + headers = { + UPLOAD_COMPLETE: FALSE, + UPLOAD_LENGTH: "100", + } + status, resp_headers, _, interim = http_request("POST", target_url, headers=headers, body=payload, test_name=request.node.name) + assert status == 201, f"Expected 201 Created, got {status}" + final_loc = resp_headers.get(LOCATION) + assert final_loc, "Final response MUST include Location" + + if interim: + final_path = urlparse(final_loc).path + for i_headers in interim: + i_loc = i_headers.get(LOCATION, "") + i_path = urlparse(i_loc).path + assert i_path == final_path, f"Location in interim 104 ({i_loc}) MUST match Location in final response ({final_loc})" + + def test_creation_upload_length_persisted_across_appends(self, target_url, request): + """ + §4.2.2: Server MUST Record Representation Length from Upload-Length. + Quote: "The server MUST record the representation's length according to Section 4.1.3 if the Upload-Length... are included" + Expected behavior: Upload-Length provided during creation MUST be persisted and returned in HEAD responses. + """ + upload_uri = create_partial_upload(target_url, test_name=request.node.name, upload_length="200") + + # Append some data + http_request("PATCH", upload_uri, + headers={UPLOAD_OFFSET: "0", UPLOAD_COMPLETE: FALSE, CONTENT_TYPE: APPLICATION_PARTIAL_UPLOAD}, + body=b"A" * 32, test_name=request.node.name) + + # Retrieval + status, resp_headers, _, _ = http_request("HEAD", upload_uri, test_name=request.node.name) + assert status in (204, 200) + assert resp_headers.get(UPLOAD_LENGTH) == "200", "Upload-Length MUST be persisted and returned" + + +class TestOffsetRetrieval: + """Tests for Offset Retrieval (Section 4.3).""" + + def test_offset_retrieval_head(self, target_url, request): + """ + §4.3: HEAD request to retrieve upload offset. + Quote: "The server SHOULD NOT generate a response with the 301, 302, or 303 status codes..." + Quote: "The response SHOULD include the Cache-Control header field with the no-store directive..." + Expected behavior: 200/204 with Upload-Offset, Upload-Complete, Upload-Limit. Cache-Control: no-store should be present. + """ + upload_uri = create_partial_upload(target_url, test_name=request.node.name) + status, resp_headers, _, _ = http_request("HEAD", upload_uri, test_name=request.node.name) + assert status in (204, 200) + assert resp_headers.get(UPLOAD_OFFSET) == "0" + assert resp_headers.get(UPLOAD_COMPLETE) == FALSE + assert "no-store" in resp_headers.get("Cache-Control", ""), "HEAD response SHOULD include Cache-Control: no-store" + assert UPLOAD_LIMIT in resp_headers, "HEAD response MUST include Upload-Limit" + assert status not in (301, 302, 303), "HEAD response SHOULD NOT redirect" + + def test_offset_retrieval_get(self, target_url, request): + """ + §4.3: GET request for offset retrieval. + Quote: "MUST indicate the limits in the Upload-Limit header field" + Expected behavior: 200/204 response with Upload-Limit, Upload-Length, and Cache-Control: no-store. + """ + upload_uri = create_partial_upload(target_url, test_name=request.node.name, upload_length="100") + status, resp_headers, _, _ = http_request("GET", upload_uri, test_name=request.node.name) + assert status in (200, 204) + assert resp_headers.get(UPLOAD_OFFSET) == "0" + assert resp_headers.get(UPLOAD_COMPLETE) == FALSE, "GET response MUST include Upload-Complete" + assert UPLOAD_LIMIT in resp_headers, "GET response MUST include Upload-Limit" + assert resp_headers.get(UPLOAD_LENGTH) == "100", "GET response MUST include Upload-Length when known" + assert "no-store" in resp_headers.get("Cache-Control", ""), "GET response SHOULD include Cache-Control: no-store" + + def test_offset_retrieval_bad_head_upload_offset(self, target_url, request): + """ + §4.3.1: HEAD request containing Upload-Offset header. + Note: Defensive compliance test enforcing client MUST NOT requirements. + Expected behavior: Server should defensively reject with 400. + """ + upload_uri = create_partial_upload(target_url, test_name=request.node.name) + headers = {UPLOAD_OFFSET: "10"} + status, _, _, _ = http_request("HEAD", upload_uri, headers=headers, test_name=request.node.name) + assert status == 400 + + def test_offset_retrieval_bad_head_upload_complete(self, target_url, request): + """ + §4.3.1: HEAD request containing Upload-Complete header. + Note: Defensive compliance test enforcing client MUST NOT requirements. + Expected behavior: Server should defensively reject with 400. + """ + upload_uri = create_partial_upload(target_url, test_name=request.node.name) + headers = {UPLOAD_COMPLETE: FALSE} + status, _, _, _ = http_request("HEAD", upload_uri, headers=headers, test_name=request.node.name) + assert status == 400 + + +class TestUploadAppend: + """Tests for Upload Append (Section 4.4 & Section 6).""" + + def test_append_partial_data(self, target_url, request): + """ + §4.4: Appending intermediate data via PATCH. + Quote: "The server SHOULD NOT generate a response with the 301, 302, or 303 status codes..." + Expected behavior: 200/204 response. 104 interim response MUST NOT include Location. Should not redirect. + """ + upload_uri = create_partial_upload(target_url, test_name=request.node.name) + headers = { + UPLOAD_OFFSET: "0", + UPLOAD_COMPLETE: FALSE, + CONTENT_TYPE: APPLICATION_PARTIAL_UPLOAD, + } + status, resp_headers, _, interim = http_request("PATCH", upload_uri, headers=headers, body=b"A" * 32, test_name=request.node.name) + assert status in (204, 200) + assert resp_headers.get(UPLOAD_COMPLETE) == FALSE + assert status not in (301, 302, 303), "Append MUST NOT respond with 301/302/303 redirect" + + resp_offset = resp_headers.get(UPLOAD_OFFSET) + if resp_offset: + assert resp_offset == "32", f"Upload-Offset in response should be 32, got {resp_offset}" + + if interim: + for i_headers in interim: + assert LOCATION not in i_headers, "104 interim response on append MUST NOT include Location" + + def test_append_missing_upload_offset(self, target_url, request): + """ + §4.4.1: Upload-Offset MUST be included in PATCH append requests. + Expected behavior: Server must reject the request. + """ + upload_uri = create_partial_upload(target_url, test_name=request.node.name) + headers = { + UPLOAD_COMPLETE: FALSE, + CONTENT_TYPE: APPLICATION_PARTIAL_UPLOAD, + } + status, _, _, _ = http_request("PATCH", upload_uri, headers=headers, body=b"A" * 32, test_name=request.node.name) + assert status in (400, 409) + + def test_append_missing_upload_complete(self, target_url, request): + """ + §4.4.1: Upload Append: MUST Include Upload-Complete. + Quote: "The request MUST include the Upload-Complete header field." + Expected behavior: Sending PATCH without Upload-Complete MUST be rejected (400). + """ + upload_uri = create_partial_upload(target_url, test_name=request.node.name) + headers = { + UPLOAD_OFFSET: "0", + CONTENT_TYPE: APPLICATION_PARTIAL_UPLOAD, + } + status, _, _, _ = http_request("PATCH", upload_uri, headers=headers, body=b"A" * 32, test_name=request.node.name) + assert status in (400, 409), "PATCH missing Upload-Complete MUST be rejected" + + def test_append_wrong_content_type(self, target_url, request): + """ + §4.4.2: Upload Append: Content-Type MUST Be application/partial-upload. + Quote: "A server applies a PATCH request with the application/partial-upload media type..." + Expected behavior: PATCH with wrong Content-Type MUST be rejected (400 or 415). + """ + upload_uri = create_partial_upload(target_url, test_name=request.node.name) + headers = { + UPLOAD_OFFSET: "0", + UPLOAD_COMPLETE: FALSE, + CONTENT_TYPE: "text/plain", + } + status, _, _, _ = http_request("PATCH", upload_uri, headers=headers, body=b"A" * 32, test_name=request.node.name) + assert status in (400, 415), "PATCH with incorrect Content-Type MUST be rejected" + + def test_append_empty_intermediate(self, target_url, request): + """ + §6: Empty intermediate append (Upload-Complete: ?0, empty body). + Expected behavior: If min-append-size > 0, should reject with 400 and include Upload-Limit. + """ + _, resp_headers, _, _ = http_request("OPTIONS", target_url, test_name=request.node.name) + limit_hdr = resp_headers.get(UPLOAD_LIMIT, "") + min_append_size = 0 + for part in limit_hdr.split(","): + if "min-append-size=" in part: + min_append_size = int(part.split("=")[1].strip()) + + upload_uri = create_partial_upload(target_url, test_name=request.node.name) + headers = { + UPLOAD_OFFSET: "0", + UPLOAD_COMPLETE: FALSE, + CONTENT_TYPE: APPLICATION_PARTIAL_UPLOAD, + } + status, resp_headers, _, _ = http_request("PATCH", upload_uri, headers=headers, body=b"", test_name=request.node.name) + if min_append_size > 0: + assert status == 400 + assert UPLOAD_LIMIT in resp_headers, "Rejection response SHOULD include Upload-Limit" + else: + assert status in (200, 204) + + def test_append_exceeding_upload_length(self, target_url, request): + """ + §4.4.2: Appending beyond the declared Upload-Length. + Quote: "the server MUST reject the request with a 409 (Conflict) status code and the Upload-Complete header field set to false..." + Expected behavior: Reject with 409, Upload-Complete ?0, resource invalidated. + """ + upload_uri = create_partial_upload(target_url, test_name=request.node.name, upload_length="50") + headers = { + UPLOAD_OFFSET: "0", + UPLOAD_COMPLETE: FALSE, + CONTENT_TYPE: APPLICATION_PARTIAL_UPLOAD, + } + status, resp_headers, _, _ = http_request("PATCH", upload_uri, headers=headers, body=b"B" * 100, test_name=request.node.name) + assert status in (400, 409) + assert resp_headers.get(UPLOAD_COMPLETE) == FALSE, "Error response for append MUST include Upload-Complete: ?0" + + # Verify resource is invalidated after exceeding length + h_status, _, _, _ = http_request("HEAD", upload_uri, test_name=request.node.name) + assert h_status not in (200, 204), "Resource should be invalidated after exceeding length" + + def test_append_exactly_at_length_then_exceed(self, target_url, request): + """ + §4.4.2: Offset Exceeding Length: Server MUST Invalidate Upload Resource. + Quote: "the server MUST prevent the offset from exceeding the representation's length by rejecting the request... marking the upload resource invalid" + Expected behavior: Appending exactly at length succeeds. Appending 1 more byte fails and invalidates resource. + """ + upload_uri = create_partial_upload(target_url, test_name=request.node.name, upload_length="50") + headers = { + UPLOAD_OFFSET: "0", + UPLOAD_COMPLETE: FALSE, + CONTENT_TYPE: APPLICATION_PARTIAL_UPLOAD, + } + # Append exact length + status, _, _, _ = http_request("PATCH", upload_uri, headers=headers, body=b"B" * 50, test_name=request.node.name) + assert status in (200, 204) + + # Exceed by 1 byte + headers = { + UPLOAD_OFFSET: "50", + UPLOAD_COMPLETE: FALSE, + CONTENT_TYPE: APPLICATION_PARTIAL_UPLOAD, + } + status_exceed, resp_headers, _, _ = http_request("PATCH", upload_uri, headers=headers, body=b"B", test_name=request.node.name) + assert status_exceed in (400, 409), "Appending beyond exact length MUST be rejected" + + # Verify invalidation + h_status, _, _, _ = http_request("HEAD", upload_uri, test_name=request.node.name) + assert h_status not in (200, 204), "Resource should be marked invalid after offset exceeds length" + + def test_append_offset_mismatch(self, target_url, request): + """ + §4.4.2 & 7.1: Mismatching Upload-Offset. + Quote: "the server MUST reject the request with a 409 (Conflict) status code and the Upload-Complete header field set to false..." + Expected behavior: Reject with 409, Upload-Complete ?0, correct Upload-Offset. + """ + upload_uri = create_partial_upload(target_url, test_name=request.node.name) + headers = { + UPLOAD_OFFSET: "99", + UPLOAD_COMPLETE: FALSE, + CONTENT_TYPE: APPLICATION_PARTIAL_UPLOAD, + } + status, resp_headers, body, _ = http_request("PATCH", upload_uri, headers=headers, body=b"A" * 32, test_name=request.node.name) + assert status == 409 + assert resp_headers.get(UPLOAD_OFFSET) == "0" + assert resp_headers.get(UPLOAD_COMPLETE) == FALSE + if resp_headers.get("Content-Type", "").startswith(APPLICATION_PROBLEM_JSON): + prob = json.loads(body.decode("utf-8")) + assert "expected-offset" in prob and prob["expected-offset"] == 0 + assert "provided-offset" in prob and prob["provided-offset"] == 99 + assert prob.get("type") == "https://iana.org/assignments/http-problem-types#mismatching-upload-offset" + + def test_append_offset_mismatch_after_partial_data(self, target_url, request): + """ + §4.4.2: Upload Append: Response MUST Include Correct Upload-Offset on 409 (after partial data). + Quote: "The response MUST include the correct offset in the Upload-Offset header field." + Expected behavior: Upload 32 bytes. Send PATCH with offset 0. Expected 409 with Upload-Offset: 32. + """ + upload_uri = create_partial_upload(target_url, test_name=request.node.name) + + # Initial valid append + http_request("PATCH", upload_uri, + headers={UPLOAD_OFFSET: "0", UPLOAD_COMPLETE: FALSE, CONTENT_TYPE: APPLICATION_PARTIAL_UPLOAD}, + body=b"A" * 32, test_name=request.node.name) + + # Stale offset append + headers = { + UPLOAD_OFFSET: "0", + UPLOAD_COMPLETE: FALSE, + CONTENT_TYPE: APPLICATION_PARTIAL_UPLOAD, + } + status, resp_headers, _, _ = http_request("PATCH", upload_uri, headers=headers, body=b"B" * 32, test_name=request.node.name) + assert status == 409, "Mismatching offset MUST be rejected with 409" + assert resp_headers.get(UPLOAD_OFFSET) == "32", "Response MUST include the correct server-side offset" + assert resp_headers.get(UPLOAD_COMPLETE) == FALSE, "Response MUST include Upload-Complete: ?0" + + def test_length_derived_from_completing_append(self, target_url, request): + """ + §4.1.3: Length Derivation from Upload-Complete: ?1 and Content-Length. + Quote: "The representation's length is then the sum of the current offset (Section 4.1.1) and the request content's length" + Expected behavior: Append with ?1 when length is unknown derives length correctly. + """ + upload_uri = create_partial_upload(target_url, test_name=request.node.name, upload_length=None) + + # Append 50 bytes and complete + headers = { + UPLOAD_OFFSET: "0", + UPLOAD_COMPLETE: TRUE, + CONTENT_TYPE: APPLICATION_PARTIAL_UPLOAD, + } + status, _, _, _ = http_request("PATCH", upload_uri, headers=headers, body=b"A" * 50, test_name=request.node.name) + assert status in (200, 204, 201) + + # Retrieve and verify length + h_status, h_headers, _, _ = http_request("HEAD", upload_uri, test_name=request.node.name) + assert h_status in (200, 204) + assert h_headers.get(UPLOAD_LENGTH) == "50", "Server MUST correctly derive Upload-Length from completing append" + + +class TestUploadCancellation: + """Tests for Upload Cancellation (Section 4.5).""" + + def test_cancellation_delete(self, target_url, request): + """ + §4.5: Cancel upload via DELETE request. + Quote: "The server SHOULD NOT generate a response with the 301, 302, or 303 status codes..." + Expected behavior: 204/200, no redirect. + """ + upload_uri = create_partial_upload(target_url, test_name=request.node.name) + status, _, _, _ = http_request("DELETE", upload_uri, test_name=request.node.name) + assert status in (204, 200) + assert status not in (301, 302, 303), "DELETE response SHOULD NOT redirect" + + def test_cancellation_delete_completed_upload(self, target_url, request): + """ + §4.5: DELETE on a completed upload resource. + Expected behavior: DELETE should succeed or return 404 if already cleaned up. + """ + payload = b"Complete" + headers = {UPLOAD_COMPLETE: TRUE, UPLOAD_LENGTH: str(len(payload))} + status, resp_headers, _, _ = http_request("POST", target_url, headers=headers, body=payload, test_name=request.node.name) + assert status in (200, 201) + loc = resp_headers.get(LOCATION) + if loc: + if not loc.startswith("http"): + parsed = urlparse(target_url) + loc = f"{parsed.scheme}://{parsed.netloc}{loc}" + del_status, _, _, _ = http_request("DELETE", loc, test_name=request.node.name) + assert del_status in (204, 200, 404) + + +class TestUploadLimitEnforcement: + """Tests for Upload Limit Enforcement (§4.1.4).""" + + def test_options_upload_limit_structured_field_format(self, target_url, request): + """ + §4.1.4: Upload-Limit MUST be a Dictionary Structured Header Field. + Quote: "a member with an unknown key MUST be ignored" + Expected behavior: Server responds with valid dictionary; test ignores unknown keys. + """ + status, resp_headers, _, _ = http_request("OPTIONS", target_url, test_name=request.node.name) + assert status in (200, 204) + limit_hdr = resp_headers.get(UPLOAD_LIMIT, "") + assert limit_hdr, "Upload-Limit MUST be present in OPTIONS response" + known_keys = {"max-size", "min-size", "max-append-size", "min-append-size", "max-age"} + has_limit = False + for part in limit_hdr.split(","): + part = part.strip() + if "=" in part: + key, val = part.split("=", 1) + key = key.strip() + val = val.strip() + if key in known_keys: + assert val.lstrip("-").isdigit(), f"Value for '{key}' must be Integer, got '{val}'" + has_limit = True + if not has_limit: + assert "min-size=0" in limit_hdr.replace(" ", ""), "If no limits, MUST use min-size=0" + + def test_creation_exceeding_max_size(self, target_url, request): + """ + §4.1.4: Server might reject uploads exceeding max-size. + Quote: "When a request is rejected because limits were violated, the response SHOULD include the Upload-Limit header field" + Expected behavior: 400 or 413 error with Upload-Limit in response. + """ + _, resp_headers, _, _ = http_request("OPTIONS", target_url, test_name=request.node.name) + limit_hdr = resp_headers.get(UPLOAD_LIMIT, "") + max_size = None + for part in limit_hdr.split(","): + if "max-size=" in part: + max_size = int(part.split("=")[1].strip()) + if max_size is None: + pytest.skip("Server does not advertise max-size") + headers = {UPLOAD_COMPLETE: TRUE, UPLOAD_LENGTH: str(max_size + 1)} + status, rej_headers, _, _ = http_request("POST", target_url, headers=headers, body=b"x", test_name=request.node.name) + assert status in (400, 413), f"Expected rejection for exceeding max-size, got {status}" + assert UPLOAD_LIMIT in rej_headers, "Rejection response SHOULD include Upload-Limit" + + def test_creation_exceeding_min_append_size(self, target_url, request): + """ + §4.1.4: min-append-size enforcement. + Expected behavior: Rejection response SHOULD include Upload-Limit. + """ + _, resp_headers, _, _ = http_request("OPTIONS", target_url, test_name=request.node.name) + limit_hdr = resp_headers.get(UPLOAD_LIMIT, "") + min_append_size = None + for part in limit_hdr.split(","): + if "min-append-size=" in part: + min_append_size = int(part.split("=")[1].strip()) + if min_append_size is None or min_append_size <= 1: + pytest.skip("Server does not advertise min-append-size > 1") + + upload_uri = create_partial_upload(target_url, test_name=request.node.name) + headers = { + UPLOAD_OFFSET: "0", + UPLOAD_COMPLETE: FALSE, + CONTENT_TYPE: APPLICATION_PARTIAL_UPLOAD, + } + body = b"x" * (min_append_size - 1) + status, rej_headers, _, _ = http_request("PATCH", upload_uri, headers=headers, body=body, test_name=request.node.name) + assert status in (400, 413), f"Expected rejection for violating min-append-size, got {status}" + assert UPLOAD_LIMIT in rej_headers, "Rejection response SHOULD include Upload-Limit" + + def test_creation_empty_body_ignores_min_append_size(self, target_url, request): + """ + §4.1.4: min-append-size Does NOT Apply to Upload Creation With No Content. + Quote: "This limit does not apply to upload creation requests with no content..." + Expected behavior: Empty body creation accepted even if min-append-size > 0. + """ + headers = {UPLOAD_COMPLETE: FALSE, UPLOAD_LENGTH: "100"} + status, _, _, _ = http_request("POST", target_url, headers=headers, body=b"", test_name=request.node.name) + assert status == 201, "Empty-body creation MUST be accepted regardless of min-append-size" + + def test_append_completing_below_min_append_size(self, target_url, request): + """ + §4.1.4: min-append-size Does NOT Apply When Upload-Complete: ?1. + Quote: "This limit does not apply to... requests completing the upload by including the Upload-Complete: ?1 header field." + Expected behavior: Completing append accepted even if below min-append-size. + """ + upload_uri = create_partial_upload(target_url, test_name=request.node.name, upload_length="1") + headers = { + UPLOAD_OFFSET: "0", + UPLOAD_COMPLETE: TRUE, + CONTENT_TYPE: APPLICATION_PARTIAL_UPLOAD, + } + status, _, _, _ = http_request("PATCH", upload_uri, headers=headers, body=b"X", test_name=request.node.name) + assert status in (200, 201, 204), "Completing append MUST be accepted regardless of min-append-size" + + def test_append_exceeding_max_append_size(self, target_url, request): + """ + §4.1.4, §4.7: Append Exceeding max-append-size Rejected With 413. + Quote: "413 (Content Too Large) can be resumed after applying appropriate limits (Section 4.1.4)." + Expected behavior: Append > max-append-size rejected with 413 and Upload-Limit included. + """ + _, resp_headers, _, _ = http_request("OPTIONS", target_url, test_name=request.node.name) + limit_hdr = resp_headers.get(UPLOAD_LIMIT, "") + max_append_size = None + for part in limit_hdr.split(","): + if "max-append-size=" in part: + max_append_size = int(part.split("=")[1].strip()) + if max_append_size is None or max_append_size > 10 * 1024 * 1024: + pytest.skip("Server max-append-size is not advertised or too large to test over socket") + + upload_uri = create_partial_upload(target_url, test_name=request.node.name) + headers = { + UPLOAD_OFFSET: "0", + UPLOAD_COMPLETE: FALSE, + CONTENT_TYPE: APPLICATION_PARTIAL_UPLOAD, + } + body = b"X" * (max_append_size + 1) + status, rej_headers, _, _ = http_request("PATCH", upload_uri, headers=headers, body=body, test_name=request.node.name) + assert status == 413, "Append exceeding max-append-size MUST be rejected with 413" + assert UPLOAD_LIMIT in rej_headers, "413 rejection SHOULD include Upload-Limit" + + +class TestUploadResourceDeactivation: + """Tests for Upload Resource Deactivation (§4.5, §4.4.2).""" + + def test_head_after_cancellation(self, target_url, request): + """ + §4.5: HEAD After DELETE (Resource Deactivation). + Quote: "the server... SHOULD deactivate the upload resource and reject further interaction with it." + Expected behavior: After successful DELETE, HEAD should return 404. + """ + upload_uri = create_partial_upload(target_url, test_name=request.node.name) + http_request("DELETE", upload_uri, test_name=request.node.name) + + status, _, _, _ = http_request("HEAD", upload_uri, test_name=request.node.name) + assert status in (404, 410), "HEAD on deactivated resource SHOULD return 404 (or 410)" + + def test_append_after_cancellation(self, target_url, request): + """ + §4.5: PATCH After DELETE (Resource Deactivation). + Quote: "the server... SHOULD deactivate the upload resource and reject further interaction with it." + Expected behavior: After successful DELETE, PATCH should return 404. + """ + upload_uri = create_partial_upload(target_url, test_name=request.node.name) + http_request("DELETE", upload_uri, test_name=request.node.name) + + headers = { + UPLOAD_OFFSET: "0", + UPLOAD_COMPLETE: FALSE, + CONTENT_TYPE: APPLICATION_PARTIAL_UPLOAD, + } + status, _, _, _ = http_request("PATCH", upload_uri, headers=headers, body=b"A", test_name=request.node.name) + assert status in (404, 410), "PATCH on deactivated resource SHOULD return 404 (or 410)" + + def test_cancellation_delete_nonexistent(self, target_url, request): + """ + §4.5: DELETE on Non-Existent Upload Resource. + Expected behavior: Should return 404. + """ + status, _, _, _ = http_request("DELETE", target_url + "/definitely-nonexistent-id", test_name=request.node.name) + assert status == 404, "DELETE on non-existent resource SHOULD return 404" + + +class TestCompletedUploadBehavior: + """Tests for interacting with completed uploads.""" + + def test_offset_retrieval_head_completed_upload(self, target_url, request): + """ + §4.3.2: Offset Retrieval: HEAD Response MUST Include Upload-Complete and Upload-Offset (for completed upload). + Quote: "MUST include the Upload-Complete header field... indicating whether a final response was produced" + Expected behavior: HEAD to a completed upload returns Upload-Complete: ?1 and Upload-Offset equal to total length. + """ + payload = b"Completed Data" + headers = {UPLOAD_COMPLETE: TRUE, UPLOAD_LENGTH: str(len(payload)), CONTENT_TYPE: "text/plain"} + status, resp_headers, _, _ = http_request("POST", target_url, headers=headers, body=payload, test_name=request.node.name) + loc = resp_headers.get(LOCATION) + if not loc: + pytest.skip("Server did not return Location for completed upload creation") + if not loc.startswith("http"): + parsed = urlparse(target_url) + loc = f"{parsed.scheme}://{parsed.netloc}{loc}" + + h_status, h_headers, _, _ = http_request("HEAD", loc, test_name=request.node.name) + assert h_status in (200, 204), "HEAD on completed upload should succeed" + assert h_headers.get(UPLOAD_COMPLETE) == TRUE, "HEAD on completed upload MUST return Upload-Complete: ?1" + assert h_headers.get(UPLOAD_OFFSET) == str(len(payload)), "HEAD on completed upload MUST return Upload-Offset equal to full length" + + def test_append_to_completed_upload(self, target_url, request): + """ + §4.4.2: Upload-Complete: Append to Already-Completed Upload. + Expected behavior: The server can replay the final response or reject with 4xx. + """ + upload_uri = create_partial_upload(target_url, test_name=request.node.name, upload_length="10") + http_request("PATCH", upload_uri, + headers={UPLOAD_OFFSET: "0", UPLOAD_COMPLETE: TRUE, CONTENT_TYPE: APPLICATION_PARTIAL_UPLOAD}, + body=b"A" * 10, test_name=request.node.name) + + # Re-append to completed + status, resp_headers, _, _ = http_request("PATCH", upload_uri, + headers={UPLOAD_OFFSET: "10", UPLOAD_COMPLETE: TRUE, CONTENT_TYPE: APPLICATION_PARTIAL_UPLOAD}, + body=b"", test_name=request.node.name) + + assert status in (200, 201, 204, 400, 409, 410, 404), "Append to completed upload should replay success or return 4xx error" + + +class TestConcurrencyAndRetry: + """Tests for Concurrency (§4.6) and Retry (§4.7).""" + + def test_concurrency_race_condition(self, target_url, request): + """§4.6: Server MUST prevent race conditions from concurrent requests.""" + upload_uri = create_partial_upload(target_url, test_name=request.node.name) + + results = [] + def do_patch(i): + headers = {UPLOAD_OFFSET: "0", UPLOAD_COMPLETE: FALSE, CONTENT_TYPE: APPLICATION_PARTIAL_UPLOAD} + st, _, _, _ = http_request("PATCH", upload_uri, headers=headers, body=b"A" * 32, test_name=request.node.name) + results.append(st) + + t1 = threading.Thread(target=do_patch, args=(1,)) + t2 = threading.Thread(target=do_patch, args=(2,)) + t1.start() + t2.start() + t1.join() + t2.join() + + successes = [r for r in results if r in (200, 204)] + assert len(successes) <= 1, "Concurrent patches at the same offset MUST NOT both succeed" + + def test_head_then_append_offset_consistency(self, target_url, request): + """§4.6: Offset from HEAD MUST be usable for the next append.""" + upload_uri = create_partial_upload(target_url, test_name=request.node.name) + _, h_headers, _, _ = http_request("HEAD", upload_uri, test_name=request.node.name) + offset = h_headers.get(UPLOAD_OFFSET, "0") + headers = {UPLOAD_OFFSET: offset, UPLOAD_COMPLETE: FALSE, CONTENT_TYPE: APPLICATION_PARTIAL_UPLOAD} + status, _, _, _ = http_request("PATCH", upload_uri, headers=headers, body=b"A" * 32, test_name=request.node.name) + assert status in (200, 204), f"Append at HEAD-reported offset must succeed, got {status}" + + def test_retry_after_409_with_correct_offset(self, target_url, request): + """§4.7: 409 Conflict can be resumed with the correct offset.""" + upload_uri = create_partial_upload(target_url, test_name=request.node.name) + status, resp_headers, _, _ = http_request("PATCH", upload_uri, + headers={UPLOAD_OFFSET: "99", UPLOAD_COMPLETE: FALSE, CONTENT_TYPE: APPLICATION_PARTIAL_UPLOAD}, + body=b"A" * 32, test_name=request.node.name) + assert status == 409 + correct_offset = resp_headers.get(UPLOAD_OFFSET) + assert correct_offset + status2, _, _, _ = http_request("PATCH", upload_uri, + headers={UPLOAD_OFFSET: correct_offset, UPLOAD_COMPLETE: FALSE, CONTENT_TYPE: APPLICATION_PARTIAL_UPLOAD}, + body=b"A" * 32, test_name=request.node.name) + assert status2 in (200, 204), f"Retry with correct offset should succeed, got {status2}" + + def test_concurrent_head_during_patch(self, target_url, request): + """ + §4.6: Concurrency: Concurrent HEAD While PATCH Is In-Flight. + Quote: "the server MUST NOT send outdated offsets" + Expected behavior: HEAD during PATCH returns consistent (not stale) offset. + """ + upload_uri = create_partial_upload(target_url, test_name=request.node.name) + + # Simulate in-flight by firing patch and head almost together + head_offsets = [] + def do_head(): + time.sleep(0.01) # Give patch a moment to start processing + _, h_headers, _, _ = http_request("HEAD", upload_uri, test_name=request.node.name) + if h_headers.get(UPLOAD_OFFSET): + head_offsets.append(int(h_headers.get(UPLOAD_OFFSET))) + + t1 = threading.Thread(target=http_request, args=("PATCH", upload_uri), kwargs={"headers": {UPLOAD_OFFSET: "0", UPLOAD_COMPLETE: FALSE, CONTENT_TYPE: APPLICATION_PARTIAL_UPLOAD}, "body": b"A" * 64, "test_name": request.node.name}) + t2 = threading.Thread(target=do_head) + t1.start() + t2.start() + t1.join() + t2.join() + + # The offset returned must be either 0 (before processing) or 64 (after processing) + if head_offsets: + assert head_offsets[0] in (0, 64), "Concurrent HEAD MUST NOT return an inconsistent/stale intermediate offset" + + def test_concurrent_delete_during_patch(self, target_url, request): + """ + §4.6: Concurrency: DELETE While PATCH Is In-Flight. + Expected behavior: DELETE succeeds and resource is deactivated. + """ + upload_uri = create_partial_upload(target_url, test_name=request.node.name) + def do_delete(): + time.sleep(0.01) + http_request("DELETE", upload_uri, test_name=request.node.name) + + t1 = threading.Thread(target=http_request, args=("PATCH", upload_uri), kwargs={"headers": {UPLOAD_OFFSET: "0", UPLOAD_COMPLETE: FALSE, CONTENT_TYPE: APPLICATION_PARTIAL_UPLOAD}, "body": b"A" * 64, "test_name": request.node.name}) + t2 = threading.Thread(target=do_delete) + t1.start() + t2.start() + t1.join() + t2.join() + + # Resource should be deactivated + st, _, _, _ = http_request("HEAD", upload_uri, test_name=request.node.name) + assert st in (404, 410), "Resource MUST be deactivated if deleted while PATCH was in-flight" + + +class TestResumableUploadLifecycle: + """Tests for Upload Strategies (§10).""" + + def test_full_resumable_upload_lifecycle(self, target_url, request): + """ + §3.1 & §10.1: Full lifecycle: create → append → HEAD → resume → complete. + Quote: "The server SHOULD include the Upload-Complete (Section 4.1.2) header field in the response..." + Expected behavior: Final PATCH response includes Upload-Complete: ?1. + """ + upload_uri = create_partial_upload(target_url, test_name=request.node.name) + status1, _, _, _ = http_request("PATCH", upload_uri, + headers={UPLOAD_OFFSET: "0", UPLOAD_COMPLETE: FALSE, CONTENT_TYPE: APPLICATION_PARTIAL_UPLOAD}, + body=b"A" * 32, test_name=request.node.name) + assert status1 in (200, 204) + + _, h_headers, _, _ = http_request("HEAD", upload_uri, test_name=request.node.name) + offset = h_headers.get(UPLOAD_OFFSET) + assert offset, "HEAD must return Upload-Offset" + + remaining = b"B" * 68 + status2, resp_headers, _, _ = http_request("PATCH", upload_uri, + headers={UPLOAD_OFFSET: offset, UPLOAD_COMPLETE: TRUE, CONTENT_TYPE: APPLICATION_PARTIAL_UPLOAD}, + body=remaining, test_name=request.node.name) + assert status2 in (200, 201, 204) + assert resp_headers.get(UPLOAD_COMPLETE) == TRUE, "Final PATCH response MUST include Upload-Complete: ?1" + + def test_careful_upload_creation(self, target_url, request): + """§10.2: Careful Upload Creation workflow.""" + headers = {UPLOAD_COMPLETE: FALSE, UPLOAD_LENGTH: "100"} + status, resp_headers, _, _ = http_request("POST", target_url, headers=headers, body=b"", test_name=request.node.name) + assert status == 201 + upload_uri = resp_headers.get(LOCATION) + if not upload_uri.startswith("http"): + parsed = urlparse(target_url) + upload_uri = f"{parsed.scheme}://{parsed.netloc}{upload_uri}" + + status2, _, _, _ = http_request("PATCH", upload_uri, + headers={UPLOAD_OFFSET: "0", UPLOAD_COMPLETE: FALSE, CONTENT_TYPE: APPLICATION_PARTIAL_UPLOAD}, + body=b"A" * 32, test_name=request.node.name) + assert status2 in (200, 204) + + def test_transparent_upgrade_to_resumable(self, target_url, request): + """ + §10.1.1: Transparent Upgrade to Resumable Uploads. + Expected behavior: POST with Upload-Complete: ?1 and full body acts as an upgrade. + """ + payload = b"Full Body Upload" + headers = {UPLOAD_COMPLETE: TRUE} + status, resp_headers, _, _ = http_request("POST", target_url, headers=headers, body=payload, test_name=request.node.name) + assert status in (200, 201), "Transparent upgrade POST MUST succeed" + assert resp_headers.get(UPLOAD_COMPLETE) == TRUE, "Response to transparent upgrade SHOULD include Upload-Complete: ?1" + + def test_options_with_upload_complete_header(self, target_url, request): + """ + §4.1.4: OPTIONS Without Upload-Complete Header vs With. + Quote: "When responding to an OPTIONS request without the Upload-Complete header field..." + Expected behavior: OPTIONS with Upload-Complete should not confuse the server. + """ + headers = {UPLOAD_COMPLETE: TRUE} + status, resp_headers, _, _ = http_request("OPTIONS", target_url, headers=headers, test_name=request.node.name) + assert status in (200, 204), "OPTIONS request with Upload-Complete MUST succeed" + + +class TestHttpDigests: + """Tests for HTTP Digests (RFC 9530).""" + + def test_creation_with_valid_content_digest(self, target_url, request): + """RFC 9530 Section 2: Valid Content-Digest header in creation request.""" + payload = b"RFC 9530 Digest Test Data" + digest_bytes = hashlib.sha256(payload).digest() + b64_digest = base64.b64encode(digest_bytes).decode("ascii") + content_digest = f"sha-256=:{b64_digest}:" + headers = { + UPLOAD_COMPLETE: TRUE, + UPLOAD_LENGTH: str(len(payload)), + "Content-Digest": content_digest, + } + status, _, _, _ = http_request("POST", target_url, headers=headers, body=payload, test_name=request.node.name) + assert status in (200, 201) + + def test_creation_with_invalid_content_digest(self, target_url, request): + """RFC 9530 Section 2: Invalid Content-Digest header.""" + payload = b"RFC 9530 Digest Test Data" + invalid_digest = "sha-256=:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=:" + headers = { + UPLOAD_COMPLETE: TRUE, + UPLOAD_LENGTH: str(len(payload)), + "Content-Digest": invalid_digest, + } + status, _, _, _ = http_request("POST", target_url, headers=headers, body=payload, test_name=request.node.name) + assert status in (400, 409) + + +# CLI Entry Point & Custom Formatted Summary Reporter +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="RUFH Draft-12 Conformity Test Runner") + parser.add_argument( + "--url", + default="http://localhost:8080/test/api/upload", + help="Target RUFH upload endpoint URL", + ) + args = parser.parse_args() + + os.environ["RUFH_URL"] = args.url + + print("=" * 70) + print(" RUFH (IETF Resumable Uploads for HTTP) Conformity Test Suite") + print(" Specification: draft-ietf-httpbis-resumable-upload-12") + print(" Target Endpoint:", args.url) + print("=" * 70) + + class CustomReporter: + def __init__(self): + self.passed = [] + self.failed = [] + self.docs = {} + + @pytest.hookimpl(tryfirst=True, hookwrapper=True) + def pytest_runtest_makereport(self, item, call): + outcome = yield + report = outcome.get_result() + if report.when == "call": + doc = item.obj.__doc__ or "No description provided." + self.docs[report.nodeid] = doc.strip() + if report.passed: + self.passed.append(report.nodeid) + elif report.failed: + if hasattr(report.longrepr, "reprcrash"): + err_text = report.longrepr.reprcrash.message + elif hasattr(report, "longreprtext"): + err_lines = [ + l.strip() + for l in report.longreprtext.splitlines() + if l.strip().startswith("E ") or l.strip().startswith("AssertionError") + ] + err_text = "\n ".join(err_lines) if err_lines else str(report.longrepr) + else: + err_text = str(report.longrepr) + self.failed.append((report.nodeid, err_text)) + + reporter = CustomReporter() + pytest.main([__file__, "-q", f"--url={args.url}"], plugins=[reporter]) + + total_tests = len(reporter.passed) + len(reporter.failed) + mod = sys.modules.get("rufh_conformity_test") + interim_set = INTERIM_RESPONSES_DETECTED + if mod and hasattr(mod, "INTERIM_RESPONSES_DETECTED"): + interim_set = interim_set | mod.INTERIM_RESPONSES_DETECTED + interim_count = len(interim_set) + + print("\n" + "=" * 70) + print(" CONFORMITY TEST RESULTS") + print("=" * 70) + print(f" Total Tests Executed: {total_tests}") + print(f" Passed: {len(reporter.passed)}") + print(f" Failed: {len(reporter.failed)}") + print(f" 104 Interim Responses: {interim_count} tests detected 104 responses") + print("=" * 70) + + if reporter.failed: + print("\n[!] DETAILED FAILURE BREAKDOWN FOR REMEDIATION:") + print("-" * 70) + for idx, (test_id, err_text) in enumerate(reporter.failed, 1): + print(f"\n{idx}. Test: {test_id}") + func_name = test_id.split("::")[-1] + print(f" Function: {func_name}") + print(f" Specification Goal:\n " + reporter.docs.get(test_id, "").replace("\n", "\n ")) + print(f" Failure Reason:\n " + err_text.replace("\n", "\n ")) + print("-" * 70) + else: + print("\n[✓] ALL RUFH DRAFT-12 CONFORMITY TESTS PASSED SUCCESSFULLY!") + + sys.exit(0 if not reporter.failed else 1) diff --git a/src/main/java/me/desair/tus/server/HttpProblemDetails.java b/src/main/java/me/desair/tus/server/HttpProblemDetails.java index d1cb9ff8..8d1c525c 100644 --- a/src/main/java/me/desair/tus/server/HttpProblemDetails.java +++ b/src/main/java/me/desair/tus/server/HttpProblemDetails.java @@ -57,6 +57,9 @@ public HttpProblemDetails( public static HttpProblemDetails forOffsetMismatch(long expectedOffset, Long providedOffset) { Map extra = new LinkedHashMap<>(); extra.put("expected-offset", expectedOffset); + if (providedOffset != null) { + extra.put("provided-offset", providedOffset); + } return new HttpProblemDetails( HttpServletResponse.SC_CONFLICT, "https://iana.org/assignments/http-problem-types#mismatching-upload-offset", @@ -146,9 +149,11 @@ public String toJson() { */ public void writeTo(HttpServletResponse response) throws IOException { Objects.requireNonNull(response, "Response cannot be null"); + byte[] jsonBytes = toJson().getBytes(java.nio.charset.StandardCharsets.UTF_8); response.setStatus(status); response.setHeader(HttpHeader.CONTENT_TYPE, HttpHeader.CONTENT_TYPE_PROBLEM_JSON); - response.getWriter().write(toJson()); + response.setContentLength(jsonBytes.length); + response.getWriter().write(new String(jsonBytes, java.nio.charset.StandardCharsets.UTF_8)); response.getWriter().flush(); } @@ -160,9 +165,11 @@ public void writeTo(HttpServletResponse response) throws IOException { */ public void writeTo(TusServletResponse response) throws IOException { Objects.requireNonNull(response, "Response cannot be null"); + byte[] jsonBytes = toJson().getBytes(java.nio.charset.StandardCharsets.UTF_8); response.setStatus(status); response.setHeader(HttpHeader.CONTENT_TYPE, HttpHeader.CONTENT_TYPE_PROBLEM_JSON); - response.getWriter().write(toJson()); + response.setContentLength(jsonBytes.length); + response.getWriter().write(new String(jsonBytes, java.nio.charset.StandardCharsets.UTF_8)); response.getWriter().flush(); } diff --git a/src/main/java/me/desair/tus/server/TusFileUploadService.java b/src/main/java/me/desair/tus/server/TusFileUploadService.java index 760c4c8f..5e47dbc3 100644 --- a/src/main/java/me/desair/tus/server/TusFileUploadService.java +++ b/src/main/java/me/desair/tus/server/TusFileUploadService.java @@ -670,11 +670,13 @@ protected void processTusException( } } - // Since an error occurred, the bytes we have written are probably not valid. So remove - // them. - UploadInfo uploadInfo = - uploadStorageService.getUploadInfo(Utils.getUploadUri(request, response), ownerKey); - uploadStorageService.removeLastNumberOfBytes(uploadInfo, request.getBytesRead()); + // Since an error occurred, the bytes we have written are probably not valid. + // So remove them. + String uploadUri = Utils.getUploadUri(request, response); + UploadInfo uploadInfo = uploadStorageService.getUploadInfo(uploadUri, ownerKey); + if (uploadInfo != null) { + uploadStorageService.removeLastNumberOfBytes(uploadInfo, request.getBytesRead()); + } } catch (TusException ex) { log.warn("An exception occurred while handling another exception", ex); diff --git a/src/main/java/me/desair/tus/server/checksum/ChecksumAlgorithm.java b/src/main/java/me/desair/tus/server/checksum/ChecksumAlgorithm.java index c6dc856d..2ea54fa6 100644 --- a/src/main/java/me/desair/tus/server/checksum/ChecksumAlgorithm.java +++ b/src/main/java/me/desair/tus/server/checksum/ChecksumAlgorithm.java @@ -204,7 +204,8 @@ public static Map parseDigestHeader(String headerValu for (Map.Entry entry : digestDict.entrySet()) { ChecksumAlgorithm alg = forHttpDigestName(entry.getKey()); if (alg != null) { - result.put(alg, cleanDigestValue((String) entry.getValue())); + String val = entry.getValue() instanceof String ? (String) entry.getValue() : null; + result.put(alg, cleanDigestValue(val)); } } } diff --git a/src/main/java/me/desair/tus/server/checksum/validation/ChecksumAlgorithmValidator.java b/src/main/java/me/desair/tus/server/checksum/validation/ChecksumAlgorithmValidator.java index e25ef16b..27fc7a06 100644 --- a/src/main/java/me/desair/tus/server/checksum/validation/ChecksumAlgorithmValidator.java +++ b/src/main/java/me/desair/tus/server/checksum/validation/ChecksumAlgorithmValidator.java @@ -1,7 +1,6 @@ package me.desair.tus.server.checksum.validation; import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; import me.desair.tus.server.HttpHeader; import me.desair.tus.server.HttpMethod; @@ -9,6 +8,7 @@ import me.desair.tus.server.checksum.ChecksumAlgorithm; import me.desair.tus.server.exception.ChecksumAlgorithmNotSupportedException; import me.desair.tus.server.exception.TusException; +import me.desair.tus.server.exception.UploadChecksumMalformedException; import me.desair.tus.server.upload.UploadStorageService; import me.desair.tus.server.util.Utils; import org.apache.commons.lang3.StringUtils; @@ -42,8 +42,7 @@ public void validate( // Check that the header is not malformed if (Utils.parseUploadChecksumHeader(request) == null) { - throw new TusException( - HttpServletResponse.SC_BAD_REQUEST, "The Upload-Checksum header is malformed"); + throw new UploadChecksumMalformedException("The Upload-Checksum header is malformed"); } } } diff --git a/src/main/java/me/desair/tus/server/creation/validation/UploadMetadataValidator.java b/src/main/java/me/desair/tus/server/creation/validation/UploadMetadataValidator.java index e6cf5f56..b0e3b605 100644 --- a/src/main/java/me/desair/tus/server/creation/validation/UploadMetadataValidator.java +++ b/src/main/java/me/desair/tus/server/creation/validation/UploadMetadataValidator.java @@ -1,10 +1,10 @@ package me.desair.tus.server.creation.validation; import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; import me.desair.tus.server.HttpHeader; import me.desair.tus.server.HttpMethod; import me.desair.tus.server.RequestValidator; +import me.desair.tus.server.exception.InvalidUploadMetadataException; import me.desair.tus.server.exception.TusException; import me.desair.tus.server.upload.UploadStorageService; import me.desair.tus.server.util.Utils; @@ -31,28 +31,24 @@ public void validate( for (String pair : pairs) { pair = pair.trim(); if (StringUtils.isBlank(pair)) { - throw new TusException( - HttpServletResponse.SC_BAD_REQUEST, "Upload-Metadata cannot contain empty pairs"); + throw new InvalidUploadMetadataException("Upload-Metadata cannot contain empty pairs"); } String[] keyValue = pair.split(" "); if (keyValue.length > 2) { - throw new TusException( - HttpServletResponse.SC_BAD_REQUEST, + throw new InvalidUploadMetadataException( "Upload-Metadata key-value pairs must be separated by a single space"); } String key = keyValue[0]; if (StringUtils.isBlank(key)) { - throw new TusException( - HttpServletResponse.SC_BAD_REQUEST, "Upload-Metadata key cannot be empty"); + throw new InvalidUploadMetadataException("Upload-Metadata key cannot be empty"); } if (keyValue.length == 2) { String value = keyValue[1]; if (!Base64.isBase64(value)) { - throw new TusException( - HttpServletResponse.SC_BAD_REQUEST, + throw new InvalidUploadMetadataException( "Upload-Metadata value must be a valid Base64 encoded string"); } } diff --git a/src/main/java/me/desair/tus/server/digest/HttpDigestsPostPutPatchRequestHandler.java b/src/main/java/me/desair/tus/server/digest/HttpDigestsPostPutPatchRequestHandler.java index c098aab2..1ddc897e 100644 --- a/src/main/java/me/desair/tus/server/digest/HttpDigestsPostPutPatchRequestHandler.java +++ b/src/main/java/me/desair/tus/server/digest/HttpDigestsPostPutPatchRequestHandler.java @@ -59,6 +59,20 @@ public HttpProblemDetails process( } } + // 4. Provide Repr-Digest / Content-Digest response header if requested + String wantContentDigest = servletRequest.getHeader(HttpHeader.WANT_CONTENT_DIGEST); + if (StringUtils.isNotBlank(wantContentDigest)) { + ChecksumAlgorithm preferredAlg = ChecksumAlgorithm.selectBestAlgorithm(wantContentDigest); + if (preferredAlg != null) { + String calculatedVal = servletRequest.getCalculatedChecksum(preferredAlg); + if (calculatedVal != null) { + servletResponse.setHeader( + HttpHeader.CONTENT_DIGEST, + preferredAlg.getHttpDigestNames().get(0) + "=:" + calculatedVal + ":"); + } + } + } + if (uploadInfo != null) { // 2. Capture client Repr-Digest and Want-Repr-Digest captureDigestPreferences(servletRequest, uploadInfo, uploadStorageService); diff --git a/src/main/java/me/desair/tus/server/digest/validation/HttpDigestsValidator.java b/src/main/java/me/desair/tus/server/digest/validation/HttpDigestsValidator.java index e3f894da..d2789ba9 100644 --- a/src/main/java/me/desair/tus/server/digest/validation/HttpDigestsValidator.java +++ b/src/main/java/me/desair/tus/server/digest/validation/HttpDigestsValidator.java @@ -1,7 +1,6 @@ package me.desair.tus.server.digest.validation; import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; import java.util.List; import java.util.Map; @@ -10,6 +9,7 @@ import me.desair.tus.server.RequestValidator; import me.desair.tus.server.checksum.ChecksumAlgorithm; import me.desair.tus.server.exception.ChecksumAlgorithmNotSupportedException; +import me.desair.tus.server.exception.InvalidHttpDigestException; import me.desair.tus.server.exception.TusException; import me.desair.tus.server.upload.UploadStorageService; import me.desair.tus.server.util.StructuredHeaderUtil; @@ -38,8 +38,7 @@ public void validate( // Step 1.2: Validate dictionary is not empty if (digestDict.isEmpty()) { - throw new TusException( - HttpServletResponse.SC_BAD_REQUEST, "Content-Digest cannot be empty"); + throw new InvalidHttpDigestException("Content-Digest cannot be empty"); } // Step 1.3: Validate that every digest algorithm specified in Content-Digest is supported @@ -63,7 +62,7 @@ public void validate( // Step 2.2: Validate dictionary is not empty if (digestDict.isEmpty()) { - throw new TusException(HttpServletResponse.SC_BAD_REQUEST, "Repr-Digest cannot be empty"); + throw new InvalidHttpDigestException("Repr-Digest cannot be empty"); } // Step 2.3: Validate that every digest algorithm specified in Repr-Digest is supported by @@ -87,8 +86,7 @@ public void validate( // Step 3.2: Validate list is not empty if (items.isEmpty()) { - throw new TusException( - HttpServletResponse.SC_BAD_REQUEST, "Want-Repr-Digest cannot be empty"); + throw new InvalidHttpDigestException("Want-Repr-Digest cannot be empty"); } // Step 3.3: Extract algorithm token names (stripping parameters like ';q=0.5') @@ -96,8 +94,7 @@ public void validate( for (String item : items) { String token = StringUtils.substringBefore(item, ";").trim(); if (!token.matches("^[a-zA-Z0-9_*./-]+$")) { - throw new TusException( - HttpServletResponse.SC_BAD_REQUEST, + throw new InvalidHttpDigestException( "Invalid token format in Want-Repr-Digest: " + token); } } @@ -107,9 +104,7 @@ public void validate( throw te; } catch (Exception e) { // Step 4: Catch structured field parsing/syntax errors and translate to HTTP 400 Bad Request - throw new TusException( - HttpServletResponse.SC_BAD_REQUEST, - "Invalid structured header format: " + e.getMessage()); + throw new InvalidHttpDigestException("Invalid structured header format: " + e.getMessage()); } } diff --git a/src/main/java/me/desair/tus/server/download/DownloadGetRequestHandler.java b/src/main/java/me/desair/tus/server/download/DownloadGetRequestHandler.java index a0f354e6..0dbff814 100644 --- a/src/main/java/me/desair/tus/server/download/DownloadGetRequestHandler.java +++ b/src/main/java/me/desair/tus/server/download/DownloadGetRequestHandler.java @@ -9,7 +9,7 @@ import me.desair.tus.server.HttpMethod; import me.desair.tus.server.ProtocolVersion; import me.desair.tus.server.exception.TusException; -import me.desair.tus.server.exception.UploadInProgressException; +import me.desair.tus.server.exception.UploadNotFoundException; import me.desair.tus.server.upload.UploadInfo; import me.desair.tus.server.upload.UploadStorageService; import me.desair.tus.server.util.AbstractRequestHandler; @@ -29,8 +29,7 @@ public boolean supports(HttpMethod method) { @Override public boolean supports(HttpMethod method, ProtocolVersion version) { - return HttpMethod.GET.equals(method) - && (version == ProtocolVersion.TUS_1_0_0 || version == ProtocolVersion.RUFH); + return supports(method); } @Override @@ -43,28 +42,31 @@ public void process( throws IOException, TusException { UploadInfo info = uploadStorageService.getUploadInfo(servletRequest.getRequestURI(), ownerKey); - if (info == null || info.isUploadInProgress() || info.isExpired()) { - throw new UploadInProgressException( - "Upload " - + servletRequest.getRequestURI() - + " is still in progress " - + "and cannot be downloaded yet"); - } else { + if (info == null || info.isExpired()) { - servletResponse.setHeader(HttpHeader.CONTENT_LENGTH, Objects.toString(info.getLength())); + throw new UploadNotFoundException("The requested upload cannot be found or has expired"); + } - servletResponse.setHeader( - HttpHeader.CONTENT_DISPOSITION, - String.format( - CONTENT_DISPOSITION_FORMAT, - info.getFileName().replace("\"", ""), - URLEncoder.encode(info.getFileName(), StandardCharsets.UTF_8.toString()) - .replace("+", "%20"))); + if (info.isUploadInProgress()) { + // Delegate to RufhHeadGetRequestHandler for RUFH offset retrieval on in-progress uploads + servletResponse.setStatus(HttpServletResponse.SC_NO_CONTENT); + servletResponse.setHeader(HttpHeader.CONTENT_LENGTH, "0"); + return; + } - servletResponse.setHeader(HttpHeader.CONTENT_TYPE, info.getFileMimeType()); + servletResponse.setHeader(HttpHeader.CONTENT_LENGTH, Objects.toString(info.getLength())); - uploadStorageService.copyUploadTo(info, servletResponse.getOutputStream()); - } + servletResponse.setHeader( + HttpHeader.CONTENT_DISPOSITION, + String.format( + CONTENT_DISPOSITION_FORMAT, + info.getFileName().replace("\"", ""), + URLEncoder.encode(info.getFileName(), StandardCharsets.UTF_8.toString()) + .replace("+", "%20"))); + + servletResponse.setHeader(HttpHeader.CONTENT_TYPE, info.getFileMimeType()); + + uploadStorageService.copyUploadTo(info, servletResponse.getOutputStream()); servletResponse.setStatus(HttpServletResponse.SC_OK); } diff --git a/src/main/java/me/desair/tus/server/download/DownloadUploadMetadataHandler.java b/src/main/java/me/desair/tus/server/download/DownloadUploadMetadataHandler.java index 2326f50d..f5e40e7b 100644 --- a/src/main/java/me/desair/tus/server/download/DownloadUploadMetadataHandler.java +++ b/src/main/java/me/desair/tus/server/download/DownloadUploadMetadataHandler.java @@ -21,7 +21,7 @@ public boolean supports(HttpMethod method) { @Override public boolean supports(HttpMethod method, ProtocolVersion version) { - return HttpMethod.GET.equals(method) && version == ProtocolVersion.TUS_1_0_0; + return supports(method); } @Override diff --git a/src/main/java/me/desair/tus/server/exception/InvalidHeadRequestException.java b/src/main/java/me/desair/tus/server/exception/InvalidHeadRequestException.java new file mode 100644 index 00000000..f6ae6ea5 --- /dev/null +++ b/src/main/java/me/desair/tus/server/exception/InvalidHeadRequestException.java @@ -0,0 +1,11 @@ +package me.desair.tus.server.exception; + +import jakarta.servlet.http.HttpServletResponse; + +/** Exception thrown when a HEAD status request contains forbidden headers. */ +public class InvalidHeadRequestException extends TusException { + + public InvalidHeadRequestException(String message) { + super(HttpServletResponse.SC_BAD_REQUEST, message); + } +} diff --git a/src/main/java/me/desair/tus/server/exception/InvalidHttpDigestException.java b/src/main/java/me/desair/tus/server/exception/InvalidHttpDigestException.java new file mode 100644 index 00000000..564d713a --- /dev/null +++ b/src/main/java/me/desair/tus/server/exception/InvalidHttpDigestException.java @@ -0,0 +1,14 @@ +package me.desair.tus.server.exception; + +import jakarta.servlet.http.HttpServletResponse; + +/** + * Exception thrown when HTTP Digest headers (Content-Digest, Repr-Digest, Want-Repr-Digest) are + * invalid. + */ +public class InvalidHttpDigestException extends TusException { + + public InvalidHttpDigestException(String message) { + super(HttpServletResponse.SC_BAD_REQUEST, message); + } +} diff --git a/src/main/java/me/desair/tus/server/exception/InvalidUploadCompleteHeaderException.java b/src/main/java/me/desair/tus/server/exception/InvalidUploadCompleteHeaderException.java new file mode 100644 index 00000000..723a5f3f --- /dev/null +++ b/src/main/java/me/desair/tus/server/exception/InvalidUploadCompleteHeaderException.java @@ -0,0 +1,11 @@ +package me.desair.tus.server.exception; + +import jakarta.servlet.http.HttpServletResponse; + +/** Exception thrown when the Upload-Complete header is missing or invalid. */ +public class InvalidUploadCompleteHeaderException extends TusException { + + public InvalidUploadCompleteHeaderException(String message) { + super(HttpServletResponse.SC_BAD_REQUEST, message); + } +} diff --git a/src/main/java/me/desair/tus/server/exception/InvalidUploadMetadataException.java b/src/main/java/me/desair/tus/server/exception/InvalidUploadMetadataException.java new file mode 100644 index 00000000..b4102eaa --- /dev/null +++ b/src/main/java/me/desair/tus/server/exception/InvalidUploadMetadataException.java @@ -0,0 +1,11 @@ +package me.desair.tus.server.exception; + +import jakarta.servlet.http.HttpServletResponse; + +/** Exception thrown when the Upload-Metadata header is invalid or malformed. */ +public class InvalidUploadMetadataException extends TusException { + + public InvalidUploadMetadataException(String message) { + super(HttpServletResponse.SC_BAD_REQUEST, message); + } +} diff --git a/src/main/java/me/desair/tus/server/exception/InvalidUploadOffsetHeaderException.java b/src/main/java/me/desair/tus/server/exception/InvalidUploadOffsetHeaderException.java new file mode 100644 index 00000000..22eb7a60 --- /dev/null +++ b/src/main/java/me/desair/tus/server/exception/InvalidUploadOffsetHeaderException.java @@ -0,0 +1,11 @@ +package me.desair.tus.server.exception; + +import jakarta.servlet.http.HttpServletResponse; + +/** Exception thrown when the Upload-Offset header is missing or malformed in a request. */ +public class InvalidUploadOffsetHeaderException extends TusException { + + public InvalidUploadOffsetHeaderException(String message) { + super(HttpServletResponse.SC_BAD_REQUEST, message); + } +} diff --git a/src/main/java/me/desair/tus/server/exception/MaxAppendSizeExceededException.java b/src/main/java/me/desair/tus/server/exception/MaxAppendSizeExceededException.java new file mode 100644 index 00000000..ebbf8d00 --- /dev/null +++ b/src/main/java/me/desair/tus/server/exception/MaxAppendSizeExceededException.java @@ -0,0 +1,11 @@ +package me.desair.tus.server.exception; + +import jakarta.servlet.http.HttpServletResponse; + +/** Exception thrown when the request payload size exceeds the maximum allowed append size. */ +public class MaxAppendSizeExceededException extends TusException { + + public MaxAppendSizeExceededException(String message) { + super(HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE, message); + } +} diff --git a/src/main/java/me/desair/tus/server/exception/MinAppendSizeNotMetException.java b/src/main/java/me/desair/tus/server/exception/MinAppendSizeNotMetException.java new file mode 100644 index 00000000..b70b37e3 --- /dev/null +++ b/src/main/java/me/desair/tus/server/exception/MinAppendSizeNotMetException.java @@ -0,0 +1,11 @@ +package me.desair.tus.server.exception; + +import jakarta.servlet.http.HttpServletResponse; + +/** Exception thrown when request payload size is below the minimum allowed append size. */ +public class MinAppendSizeNotMetException extends TusException { + + public MinAppendSizeNotMetException(String message) { + super(HttpServletResponse.SC_BAD_REQUEST, message); + } +} diff --git a/src/main/java/me/desair/tus/server/exception/MinUploadLengthNotReachedException.java b/src/main/java/me/desair/tus/server/exception/MinUploadLengthNotReachedException.java new file mode 100644 index 00000000..f8c36a04 --- /dev/null +++ b/src/main/java/me/desair/tus/server/exception/MinUploadLengthNotReachedException.java @@ -0,0 +1,11 @@ +package me.desair.tus.server.exception; + +import jakarta.servlet.http.HttpServletResponse; + +/** Exception thrown when requested upload length is smaller than the minimum allowed size. */ +public class MinUploadLengthNotReachedException extends TusException { + + public MinUploadLengthNotReachedException(String message) { + super(HttpServletResponse.SC_BAD_REQUEST, message); + } +} diff --git a/src/main/java/me/desair/tus/server/exception/UnsafePathException.java b/src/main/java/me/desair/tus/server/exception/UnsafePathException.java new file mode 100644 index 00000000..45ff72eb --- /dev/null +++ b/src/main/java/me/desair/tus/server/exception/UnsafePathException.java @@ -0,0 +1,11 @@ +package me.desair.tus.server.exception; + +import jakarta.servlet.http.HttpServletResponse; + +/** Exception thrown when request URI contains unsafe path traversal components. */ +public class UnsafePathException extends TusException { + + public UnsafePathException(String message) { + super(HttpServletResponse.SC_BAD_REQUEST, message); + } +} diff --git a/src/main/java/me/desair/tus/server/exception/UnsupportedMediaTypeException.java b/src/main/java/me/desair/tus/server/exception/UnsupportedMediaTypeException.java new file mode 100644 index 00000000..89a0001b --- /dev/null +++ b/src/main/java/me/desair/tus/server/exception/UnsupportedMediaTypeException.java @@ -0,0 +1,11 @@ +package me.desair.tus.server.exception; + +import jakarta.servlet.http.HttpServletResponse; + +/** Exception thrown when the Content-Type header specifies an unsupported media type. */ +public class UnsupportedMediaTypeException extends TusException { + + public UnsupportedMediaTypeException(String message) { + super(HttpServletResponse.SC_UNSUPPORTED_MEDIA_TYPE, message); + } +} diff --git a/src/main/java/me/desair/tus/server/exception/UploadChecksumMalformedException.java b/src/main/java/me/desair/tus/server/exception/UploadChecksumMalformedException.java new file mode 100644 index 00000000..07e80548 --- /dev/null +++ b/src/main/java/me/desair/tus/server/exception/UploadChecksumMalformedException.java @@ -0,0 +1,11 @@ +package me.desair.tus.server.exception; + +import jakarta.servlet.http.HttpServletResponse; + +/** Exception thrown when the Upload-Checksum header is malformed. */ +public class UploadChecksumMalformedException extends TusException { + + public UploadChecksumMalformedException(String message) { + super(HttpServletResponse.SC_BAD_REQUEST, message); + } +} diff --git a/src/main/java/me/desair/tus/server/exception/UploadInProgressException.java b/src/main/java/me/desair/tus/server/exception/UploadInProgressException.java deleted file mode 100644 index 37eb7a6d..00000000 --- a/src/main/java/me/desair/tus/server/exception/UploadInProgressException.java +++ /dev/null @@ -1,14 +0,0 @@ -package me.desair.tus.server.exception; - -/** - * Exception thrown when accessing an upload that is still in progress and this is not supported by - * the operation. - */ -public class UploadInProgressException extends TusException { - /** Constructor. */ - public UploadInProgressException(String message) { - // 422 Unprocessable Entity - // The request was well-formed but was unable to be followed due to semantic errors. - super(422, message); - } -} diff --git a/src/main/java/me/desair/tus/server/exception/UploadLengthExceededException.java b/src/main/java/me/desair/tus/server/exception/UploadLengthExceededException.java new file mode 100644 index 00000000..c1f69626 --- /dev/null +++ b/src/main/java/me/desair/tus/server/exception/UploadLengthExceededException.java @@ -0,0 +1,11 @@ +package me.desair.tus.server.exception; + +import jakarta.servlet.http.HttpServletResponse; + +/** Exception thrown when appended content pushes total offset past declared upload length. */ +public class UploadLengthExceededException extends TusException { + + public UploadLengthExceededException(String message) { + super(HttpServletResponse.SC_CONFLICT, message); + } +} diff --git a/src/main/java/me/desair/tus/server/rufh/ResumableUploadsForHttpProtocol.java b/src/main/java/me/desair/tus/server/rufh/ResumableUploadsForHttpProtocol.java index 44596494..2032b064 100644 --- a/src/main/java/me/desair/tus/server/rufh/ResumableUploadsForHttpProtocol.java +++ b/src/main/java/me/desair/tus/server/rufh/ResumableUploadsForHttpProtocol.java @@ -51,6 +51,7 @@ public Collection getMinimalSupportedHttpMethods() { HttpMethod.HEAD, HttpMethod.GET, HttpMethod.POST, + HttpMethod.PUT, HttpMethod.PATCH, HttpMethod.DELETE); } diff --git a/src/main/java/me/desair/tus/server/rufh/handler/RufhCreationPostRequestHandler.java b/src/main/java/me/desair/tus/server/rufh/handler/RufhCreationPostRequestHandler.java index ac091128..63f4ea3a 100644 --- a/src/main/java/me/desair/tus/server/rufh/handler/RufhCreationPostRequestHandler.java +++ b/src/main/java/me/desair/tus/server/rufh/handler/RufhCreationPostRequestHandler.java @@ -50,7 +50,7 @@ public HttpProblemDetails process( throws IOException, TusException { if (HttpMethod.PATCH.equals(method) - && isExistingUpload(servletRequest, uploadStorageService, ownerKey)) { + && Utils.isExistingUploadResource(servletRequest, uploadStorageService, ownerKey)) { // Existing upload on PATCH request is handled by RufhAppendPatchRequestHandler return null; } @@ -114,13 +114,6 @@ && isExistingUpload(servletRequest, uploadStorageService, ownerKey)) { return null; } - private boolean isExistingUpload( - TusServletRequest request, UploadStorageService uploadStorageService, String ownerKey) - throws IOException { - String requestUri = request.getRequestURI(); - return uploadStorageService.getUploadInfo(requestUri, ownerKey) != null; - } - private boolean isUploadCompleted(UploadInfo uploadInfo) { return !uploadInfo.isUploadInProgress(); } diff --git a/src/main/java/me/desair/tus/server/rufh/handler/RufhDeleteRequestHandler.java b/src/main/java/me/desair/tus/server/rufh/handler/RufhDeleteRequestHandler.java index 01d5aec0..35b9f2cc 100644 --- a/src/main/java/me/desair/tus/server/rufh/handler/RufhDeleteRequestHandler.java +++ b/src/main/java/me/desair/tus/server/rufh/handler/RufhDeleteRequestHandler.java @@ -4,6 +4,7 @@ import me.desair.tus.server.HttpMethod; import me.desair.tus.server.HttpProblemDetails; import me.desair.tus.server.exception.TusException; +import me.desair.tus.server.exception.UploadNotFoundException; import me.desair.tus.server.upload.UploadInfo; import me.desair.tus.server.upload.UploadLockingService; import me.desair.tus.server.upload.UploadStorageService; @@ -40,9 +41,11 @@ public HttpProblemDetails process( String requestUri = servletRequest.getRequestURI(); UploadInfo uploadInfo = uploadStorageService.getUploadInfo(requestUri, ownerKey); - if (uploadInfo != null) { - uploadStorageService.terminateUpload(uploadInfo); + if (uploadInfo == null) { + throw new UploadNotFoundException("Upload resource not found"); } + + uploadStorageService.terminateUpload(uploadInfo); servletResponse.setStatus(204); return null; } diff --git a/src/main/java/me/desair/tus/server/rufh/handler/RufhErrorHandler.java b/src/main/java/me/desair/tus/server/rufh/handler/RufhErrorHandler.java index c2bd5a0b..141c05fb 100644 --- a/src/main/java/me/desair/tus/server/rufh/handler/RufhErrorHandler.java +++ b/src/main/java/me/desair/tus/server/rufh/handler/RufhErrorHandler.java @@ -55,6 +55,12 @@ public HttpProblemDetails process( TusException exception) throws IOException, TusException { + // Section 4.4.2 & §4.1.2: RUFH error responses SHOULD/MUST include Upload-Complete: ?0 + if (servletResponse.getHeader(HttpHeader.UPLOAD_COMPLETE) == null) { + servletResponse.setHeader( + HttpHeader.UPLOAD_COMPLETE, StructuredHeaderUtil.formatBoolean(false)); + } + if (exception instanceof UploadOffsetMismatchException) { // Section 7.1: Mismatching Offset UploadInfo uploadInfo = @@ -65,6 +71,10 @@ public HttpProblemDetails process( StructuredHeaderUtil.parseInteger(servletRequest.getHeader(HttpHeader.UPLOAD_OFFSET)); long provided = providedOffset != null ? providedOffset : 0L; + servletResponse.setHeader(HttpHeader.UPLOAD_OFFSET, String.valueOf(expectedOffset)); + servletResponse.setHeader( + HttpHeader.UPLOAD_COMPLETE, StructuredHeaderUtil.formatBoolean(false)); + return HttpProblemDetails.forOffsetMismatch(expectedOffset, provided); } else if (exception instanceof UploadAlreadyCompletedException) { @@ -72,7 +82,9 @@ public HttpProblemDetails process( return HttpProblemDetails.forCompletedUpload(400); } else if (exception instanceof InconsistentUploadLengthException) { - // Section 7.3: Inconsistent Length + // Section 7.2: Inconsistent Length + servletResponse.setHeader( + HttpHeader.UPLOAD_COMPLETE, StructuredHeaderUtil.formatBoolean(false)); return HttpProblemDetails.forInconsistentLength(); } else if (exception instanceof UploadDigestMismatchException) { // RFC 9530 Mismatched Digest Values diff --git a/src/main/java/me/desair/tus/server/rufh/handler/RufhHeadGetRequestHandler.java b/src/main/java/me/desair/tus/server/rufh/handler/RufhHeadGetRequestHandler.java index ed58186d..85d4ee9c 100644 --- a/src/main/java/me/desair/tus/server/rufh/handler/RufhHeadGetRequestHandler.java +++ b/src/main/java/me/desair/tus/server/rufh/handler/RufhHeadGetRequestHandler.java @@ -5,6 +5,7 @@ import me.desair.tus.server.HttpMethod; import me.desair.tus.server.HttpProblemDetails; import me.desair.tus.server.exception.TusException; +import me.desair.tus.server.exception.UploadNotFoundException; import me.desair.tus.server.upload.UploadInfo; import me.desair.tus.server.upload.UploadLockingService; import me.desair.tus.server.upload.UploadStorageService; @@ -12,6 +13,7 @@ import me.desair.tus.server.util.StructuredHeaderUtil; import me.desair.tus.server.util.TusServletRequest; import me.desair.tus.server.util.TusServletResponse; +import me.desair.tus.server.util.Utils; /** * Request handler for HTTP HEAD and GET offset retrieval requests against upload resources. @@ -53,7 +55,11 @@ public HttpProblemDetails process( String requestUri = servletRequest.getRequestURI(); UploadInfo uploadInfo = uploadStorageService.getUploadInfo(requestUri, ownerKey); - if (uploadInfo == null || uploadInfo.isExpired()) { + if (!Utils.isCreationEndpoint(servletRequest, uploadStorageService)) { + if (uploadInfo == null || uploadInfo.isExpired()) { + throw new UploadNotFoundException("Upload resource not found"); + } + } else if (uploadInfo == null || uploadInfo.isExpired()) { return null; } diff --git a/src/main/java/me/desair/tus/server/rufh/handler/RufhUploadLimitHeaderRequestHandler.java b/src/main/java/me/desair/tus/server/rufh/handler/RufhUploadLimitHeaderRequestHandler.java index a279dc73..c71f2d16 100644 --- a/src/main/java/me/desair/tus/server/rufh/handler/RufhUploadLimitHeaderRequestHandler.java +++ b/src/main/java/me/desair/tus/server/rufh/handler/RufhUploadLimitHeaderRequestHandler.java @@ -29,7 +29,8 @@ public boolean supports(HttpMethod method) { @Override public boolean supports(HttpMethod method, ProtocolVersion version) { - return version == ProtocolVersion.RUFH && supports(method); + return (version == ProtocolVersion.RUFH || HttpMethod.OPTIONS.equals(method)) + && supports(method); } @Override @@ -46,11 +47,17 @@ public void process( } String uploadUri = servletResponse.getHeader(HttpHeader.LOCATION); - if (StringUtils.isBlank(uploadUri)) { + if (StringUtils.isBlank(uploadUri) && servletRequest != null) { uploadUri = servletRequest.getRequestURI(); } - UploadInfo uploadInfo = uploadStorageService.getUploadInfo(uploadUri, ownerKey); + UploadInfo uploadInfo = null; + try { + uploadInfo = uploadStorageService.getUploadInfo(uploadUri, ownerKey); + } catch (Exception e) { + uploadInfo = null; + } + addUploadLimitHeader(servletResponse, uploadStorageService, uploadInfo); } diff --git a/src/main/java/me/desair/tus/server/rufh/util/RufhInterimResponseUtil.java b/src/main/java/me/desair/tus/server/rufh/util/RufhInterimResponseUtil.java index b3324dd1..a5aa37d8 100644 --- a/src/main/java/me/desair/tus/server/rufh/util/RufhInterimResponseUtil.java +++ b/src/main/java/me/desair/tus/server/rufh/util/RufhInterimResponseUtil.java @@ -49,19 +49,19 @@ public static String getRawInterimResponse( String uploadUri; try { - boolean isExisting = - existingUploadUri != null - && uploadStorageService.getUploadInfo(existingUploadUri, ownerKey) != null; - - if (isExisting) { - uploadUri = existingUploadUri; - } else { - UploadInfo uploadInfo = new UploadInfo(); - uploadInfo = uploadStorageService.create(uploadInfo, ownerKey); - uploadUri = Utils.getUploadUriOnCreation(uploadInfo, servletRequest, uploadStorageService); - - servletRequest.setAttribute("me.desair.tus.preCreatedUploadInfo", uploadInfo); + UploadInfo uploadInfo = uploadStorageService.getUploadInfo(existingUploadUri, ownerKey); + + if (uploadInfo != null) { + long offset = uploadInfo.getOffset() != null ? uploadInfo.getOffset() : 0L; + return getRawInterimResponseForAppend(offset); } + + uploadInfo = new UploadInfo(); + uploadInfo = uploadStorageService.create(uploadInfo, ownerKey); + uploadUri = Utils.getUploadUriOnCreation(uploadInfo, servletRequest, uploadStorageService); + + servletRequest.setAttribute("me.desair.tus.preCreatedUploadInfo", uploadInfo); + } catch (Exception e) { return null; } @@ -91,11 +91,13 @@ public static String getRawInterimResponse(String uploadUri, long offset, String } /** - * Generates the raw HTTP 104 interim response frame string for a given upload URI and offset. + * Generates the raw HTTP 104 interim response frame string for an upload creation request. * - * @param uploadUri The location URI of the upload - * @param offset The initial upload offset (typically 0) - * @return The formatted HTTP 104 response frame string + *

Reference: Section 4.2.2 of draft-ietf-httpbis-resumable-upload-12. + * + * @param uploadUri The location URI of the created upload resource + * @param offset The upload offset + * @return The formatted HTTP 104 response frame string, or null if uploadUri is null */ public static String getRawInterimResponse(String uploadUri, long offset) { if (uploadUri == null) { @@ -108,4 +110,21 @@ public static String getRawInterimResponse(String uploadUri, long offset) { sb.append("\r\n"); return sb.toString(); } + + /** + * Generates the raw HTTP 104 interim response frame string for an upload append request. + * + *

Reference: Section 4.4.2 of draft-ietf-httpbis-resumable-upload-12 ("These interim responses + * MUST NOT include the Location header field"). + * + * @param offset The current upload offset + * @return The formatted HTTP 104 response frame string for append + */ + public static String getRawInterimResponseForAppend(long offset) { + StringBuilder sb = new StringBuilder(); + sb.append("HTTP/1.1 104 Upload Resumption Supported\r\n"); + sb.append("Upload-Offset: ").append(offset).append("\r\n"); + sb.append("\r\n"); + return sb.toString(); + } } diff --git a/src/main/java/me/desair/tus/server/rufh/validation/RufhAppendValidator.java b/src/main/java/me/desair/tus/server/rufh/validation/RufhAppendValidator.java index 4d54bac9..ea923152 100644 --- a/src/main/java/me/desair/tus/server/rufh/validation/RufhAppendValidator.java +++ b/src/main/java/me/desair/tus/server/rufh/validation/RufhAppendValidator.java @@ -6,12 +6,20 @@ import me.desair.tus.server.HttpMethod; import me.desair.tus.server.RequestValidator; import me.desair.tus.server.exception.InconsistentUploadLengthException; +import me.desair.tus.server.exception.InvalidUploadCompleteHeaderException; +import me.desair.tus.server.exception.InvalidUploadOffsetHeaderException; +import me.desair.tus.server.exception.MaxAppendSizeExceededException; +import me.desair.tus.server.exception.MinAppendSizeNotMetException; import me.desair.tus.server.exception.TusException; +import me.desair.tus.server.exception.UnsupportedMediaTypeException; import me.desair.tus.server.exception.UploadAlreadyCompletedException; +import me.desair.tus.server.exception.UploadLengthExceededException; +import me.desair.tus.server.exception.UploadNotFoundException; import me.desair.tus.server.exception.UploadOffsetMismatchException; import me.desair.tus.server.upload.UploadInfo; import me.desair.tus.server.upload.UploadStorageService; import me.desair.tus.server.util.StructuredHeaderUtil; +import me.desair.tus.server.util.Utils; import org.apache.commons.lang3.Strings; /** @@ -46,36 +54,67 @@ public void validate( throws TusException, IOException { String requestUri = request.getRequestURI(); + boolean isCreationEndpoint = Utils.isCreationEndpoint(request, uploadStorageService); UploadInfo uploadInfo = uploadStorageService.getUploadInfo(requestUri, ownerKey); - // If upload is null or expired, check if this is the creation endpoint. If not, the upload - // resource was not found. if (uploadInfo == null || uploadInfo.isExpired()) { - String baseUri = uploadStorageService.getUploadUri(); - if (baseUri != null && !requestUri.equals(baseUri) && !requestUri.equals(baseUri + "/")) { - throw new TusException(404, "Upload resource not found"); + if (!isCreationEndpoint) { + throw new UploadNotFoundException("Upload resource not found"); } return; } + String uploadCompleteHeader = request.getHeader(HttpHeader.UPLOAD_COMPLETE); + if (uploadCompleteHeader == null) { + throw new InvalidUploadCompleteHeaderException( + "PATCH append request MUST include Upload-Complete header field"); + } + String contentType = request.getHeader(HttpHeader.CONTENT_TYPE); if (!Strings.CS.startsWith(contentType, HttpHeader.CONTENT_TYPE_PARTIAL_UPLOAD) && !Strings.CS.startsWith(contentType, "application/offset+octet-stream")) { - throw new TusException(415, "Unsupported Content-Type for append request"); + throw new UnsupportedMediaTypeException("Unsupported Content-Type for append request"); } if (!uploadInfo.isUploadInProgress()) { + try { + // Section 4.4.2: Deactivate upload resource when append is attempted on a completed upload + // Terminate/deactivate upload resource per §4.4.2 when append is attempted past + // declared length + uploadStorageService.terminateUpload(uploadInfo); + } catch (Exception e) { + // Log or ignore cleanup failure + } throw new UploadAlreadyCompletedException("Upload resource is already completed"); } - Long maxAppendSize = uploadStorageService.getMaxAppendSize(); + String offsetHeader = request.getHeader(HttpHeader.UPLOAD_OFFSET); + Long providedOffset = StructuredHeaderUtil.parseInteger(offsetHeader); + if (providedOffset == null) { + throw new InvalidUploadOffsetHeaderException("Missing or invalid Upload-Offset header"); + } + + long currentOffset = uploadInfo.getOffset(); + if (providedOffset != currentOffset) { + // Section 4.4.2: Offset Mismatch Error Response + // "If the Upload-Offset request header field value does not match the current offset... the + // server MUST reject + // the request with a 409 (Conflict) status code... The response MUST include the correct + // offset in the Upload-Offset header field." + throw new UploadOffsetMismatchException( + "Upload-Offset " + providedOffset + " does not match server offset " + currentOffset); + } + long contentLength = request.getContentLengthLong(); + + // Section 4.1.4 & Section 4.7: Validate max-append-size first to reject oversized payloads with + // 413 Payload Too Large + Long maxAppendSize = uploadStorageService.getMaxAppendSize(); if (maxAppendSize != null && maxAppendSize > 0 && contentLength > 0 && contentLength > maxAppendSize) { - throw new TusException( - 413, + throw new MaxAppendSizeExceededException( "The request payload size (" + contentLength + ") exceeds the maximum allowed append size (" @@ -83,18 +122,42 @@ public void validate( + ")"); } + // Section 4.4.2: Prevent offset from exceeding upload length if length is known and invalidate + // resource + // "the server MUST prevent the offset from exceeding the representation's length by rejecting + // the request + // once the offset exceeds the length, marking the upload resource invalid and rejecting any + // further interaction with it." + // When appended bytes cause offset to exceed declared length (or if upload is already + // at declared length), deactivate/terminate the upload resource per §4.4.2 and reject + // with 409 Conflict. + if (uploadInfo.hasLength()) { + if (currentOffset >= uploadInfo.getLength() + || (contentLength > 0 && currentOffset + contentLength > uploadInfo.getLength())) { + try { + uploadStorageService.terminateUpload(uploadInfo); + } catch (Exception e) { + // Log or ignore cleanup failure + } + throw new UploadLengthExceededException( + "Appended content length (" + + contentLength + + ") pushes total offset past declared upload length (" + + uploadInfo.getLength() + + ")"); + } + } + // Section 4.1.4: min-append-size validation with exemption for Upload-Complete: ?1 // "This limit does not apply to upload creation requests with no content, or to requests // completing the upload by including the Upload-Complete: ?1 header field." Long minAppendSize = uploadStorageService.getMinAppendSize(); - String uploadCompleteHeader = request.getHeader(HttpHeader.UPLOAD_COMPLETE); Boolean uploadComplete = StructuredHeaderUtil.parseBoolean(uploadCompleteHeader); boolean isCompleteExempt = Boolean.TRUE.equals(uploadComplete); if (minAppendSize != null && minAppendSize > 0 && !isCompleteExempt) { if (contentLength < minAppendSize) { - throw new TusException( - 400, + throw new MinAppendSizeNotMetException( "The request payload size (" + contentLength + ") is below the minimum allowed append size (" @@ -103,31 +166,6 @@ public void validate( } } - String offsetHeader = request.getHeader(HttpHeader.UPLOAD_OFFSET); - Long providedOffset = StructuredHeaderUtil.parseInteger(offsetHeader); - if (providedOffset == null) { - throw new TusException(400, "Missing or invalid Upload-Offset header"); - } - - long currentOffset = uploadInfo.getOffset(); - if (providedOffset != currentOffset) { - throw new UploadOffsetMismatchException( - "Upload-Offset " + providedOffset + " does not match server offset " + currentOffset); - } - - // Section 4.4.2: Prevent offset from exceeding upload length if length is known - if (uploadInfo.hasLength() && contentLength > 0) { - if (currentOffset + contentLength > uploadInfo.getLength()) { - throw new TusException( - 400, - "Appended content length (" - + contentLength - + ") pushes total offset past declared upload length (" - + uploadInfo.getLength() - + ")"); - } - } - // Section 4.1.3: Validate consistency of Upload-Length if provided in append request String uploadLengthHeader = request.getHeader(HttpHeader.UPLOAD_LENGTH); Long providedLength = StructuredHeaderUtil.parseInteger(uploadLengthHeader); diff --git a/src/main/java/me/desair/tus/server/rufh/validation/RufhCreationValidator.java b/src/main/java/me/desair/tus/server/rufh/validation/RufhCreationValidator.java index 53cf8559..7a9e401f 100644 --- a/src/main/java/me/desair/tus/server/rufh/validation/RufhCreationValidator.java +++ b/src/main/java/me/desair/tus/server/rufh/validation/RufhCreationValidator.java @@ -6,9 +6,14 @@ import me.desair.tus.server.HttpMethod; import me.desair.tus.server.RequestValidator; import me.desair.tus.server.exception.InconsistentUploadLengthException; +import me.desair.tus.server.exception.MaxAppendSizeExceededException; +import me.desair.tus.server.exception.MaxUploadLengthExceededException; +import me.desair.tus.server.exception.MinAppendSizeNotMetException; +import me.desair.tus.server.exception.MinUploadLengthNotReachedException; import me.desair.tus.server.exception.TusException; import me.desair.tus.server.upload.UploadStorageService; import me.desair.tus.server.util.StructuredHeaderUtil; +import me.desair.tus.server.util.Utils; /** * Request validator checking creation request limits (max upload length and max append payload @@ -23,7 +28,9 @@ public class RufhCreationValidator implements RequestValidator { @Override public boolean supports(HttpMethod method) { - return HttpMethod.POST.equals(method) || HttpMethod.PUT.equals(method); + return HttpMethod.POST.equals(method) + || HttpMethod.PUT.equals(method) + || HttpMethod.PATCH.equals(method); } @Override @@ -34,6 +41,11 @@ public void validate( String ownerKey) throws TusException, IOException { + if (HttpMethod.PATCH.equals(method) + && Utils.isExistingUploadResource(request, uploadStorageService, ownerKey)) { + return; + } + String uploadLengthHeader = request.getHeader(HttpHeader.UPLOAD_LENGTH); Long uploadLength = StructuredHeaderUtil.parseInteger(uploadLengthHeader); String uploadCompleteHeader = request.getHeader(HttpHeader.UPLOAD_COMPLETE); @@ -55,13 +67,13 @@ public void validate( long maxUploadSize = uploadStorageService.getMaxUploadSize(); if (maxUploadSize > 0 && uploadLength != null && uploadLength > maxUploadSize) { - throw new TusException(413, "The requested upload length exceeds the maximum allowed size"); + throw new MaxUploadLengthExceededException( + "The requested upload length exceeds the maximum allowed size"); } Long minSize = uploadStorageService.getMinSize(); if (minSize != null && minSize > 0 && uploadLength != null && uploadLength < minSize) { - throw new TusException( - 400, + throw new MinUploadLengthNotReachedException( "The requested upload length (" + uploadLength + ") is smaller than the minimum allowed size (" @@ -74,8 +86,7 @@ public void validate( && maxAppendSize > 0 && contentLength > 0 && contentLength > maxAppendSize) { - throw new TusException( - 413, + throw new MaxAppendSizeExceededException( "The request payload size (" + contentLength + ") exceeds the maximum allowed append size (" @@ -90,8 +101,7 @@ public void validate( boolean isContentExempt = contentLength <= 0 || Boolean.TRUE.equals(uploadComplete); if (minAppendSize != null && minAppendSize > 0 && !isContentExempt) { if (contentLength < minAppendSize) { - throw new TusException( - 400, + throw new MinAppendSizeNotMetException( "The request payload size (" + contentLength + ") is below the minimum allowed append size (" diff --git a/src/main/java/me/desair/tus/server/rufh/validation/RufhHeadHeaderValidator.java b/src/main/java/me/desair/tus/server/rufh/validation/RufhHeadHeaderValidator.java index 7fe52965..0ec1e9bb 100644 --- a/src/main/java/me/desair/tus/server/rufh/validation/RufhHeadHeaderValidator.java +++ b/src/main/java/me/desair/tus/server/rufh/validation/RufhHeadHeaderValidator.java @@ -5,6 +5,7 @@ import me.desair.tus.server.HttpHeader; import me.desair.tus.server.HttpMethod; import me.desair.tus.server.RequestValidator; +import me.desair.tus.server.exception.InvalidHeadRequestException; import me.desair.tus.server.exception.TusException; import me.desair.tus.server.upload.UploadStorageService; @@ -33,8 +34,8 @@ public void validate( if (request.getHeader(HttpHeader.UPLOAD_OFFSET) != null || request.getHeader(HttpHeader.UPLOAD_COMPLETE) != null) { - throw new TusException( - 400, "HEAD request MUST NOT contain Upload-Offset or Upload-Complete header field"); + throw new InvalidHeadRequestException( + "HEAD request MUST NOT contain Upload-Offset or Upload-Complete header field"); } } } diff --git a/src/main/java/me/desair/tus/server/rufh/validation/RufhSafePathValidator.java b/src/main/java/me/desair/tus/server/rufh/validation/RufhSafePathValidator.java index b1e17f9d..1d648556 100644 --- a/src/main/java/me/desair/tus/server/rufh/validation/RufhSafePathValidator.java +++ b/src/main/java/me/desair/tus/server/rufh/validation/RufhSafePathValidator.java @@ -5,6 +5,7 @@ import me.desair.tus.server.HttpMethod; import me.desair.tus.server.RequestValidator; import me.desair.tus.server.exception.TusException; +import me.desair.tus.server.exception.UnsafePathException; import me.desair.tus.server.upload.UploadStorageService; /** @@ -31,7 +32,7 @@ public void validate( String path = request.getRequestURI(); if (path != null && (path.contains("..") || path.contains("\0"))) { - throw new TusException(400, "Invalid or unsafe path component: " + path); + throw new UnsafePathException("Invalid or unsafe path component: " + path); } } } diff --git a/src/main/java/me/desair/tus/server/rufh/validation/RufhUploadExistsValidator.java b/src/main/java/me/desair/tus/server/rufh/validation/RufhUploadExistsValidator.java index 26e9ab38..58de13b4 100644 --- a/src/main/java/me/desair/tus/server/rufh/validation/RufhUploadExistsValidator.java +++ b/src/main/java/me/desair/tus/server/rufh/validation/RufhUploadExistsValidator.java @@ -5,8 +5,10 @@ import me.desair.tus.server.HttpMethod; import me.desair.tus.server.RequestValidator; import me.desair.tus.server.exception.TusException; +import me.desair.tus.server.exception.UploadNotFoundException; import me.desair.tus.server.upload.UploadInfo; import me.desair.tus.server.upload.UploadStorageService; +import me.desair.tus.server.util.Utils; /** * Request validator verifying that the target upload resource exists for status querying, data @@ -21,6 +23,7 @@ public class RufhUploadExistsValidator implements RequestValidator { public boolean supports(HttpMethod method) { return HttpMethod.HEAD.equals(method) || HttpMethod.GET.equals(method) + || HttpMethod.PATCH.equals(method) || HttpMethod.DELETE.equals(method); } @@ -32,10 +35,14 @@ public void validate( String ownerKey) throws TusException, IOException { + if (Utils.isCreationEndpoint(request, uploadStorageService)) { + return; + } + String requestUri = request.getRequestURI(); UploadInfo uploadInfo = uploadStorageService.getUploadInfo(requestUri, ownerKey); if (uploadInfo == null || uploadInfo.isExpired()) { - throw new TusException(404, "Upload resource not found"); + throw new UploadNotFoundException("Upload resource not found"); } } } diff --git a/src/main/java/me/desair/tus/server/upload/cache/ThreadLocalCachedStorageAndLockingService.java b/src/main/java/me/desair/tus/server/upload/cache/ThreadLocalCachedStorageAndLockingService.java index 4150c53e..5cc8a024 100644 --- a/src/main/java/me/desair/tus/server/upload/cache/ThreadLocalCachedStorageAndLockingService.java +++ b/src/main/java/me/desair/tus/server/upload/cache/ThreadLocalCachedStorageAndLockingService.java @@ -51,6 +51,9 @@ public ThreadLocalCachedStorageAndLockingService( @Override public UploadInfo getUploadInfo(UploadId id) throws IOException { + if (id == null) { + return null; + } UploadInfo uploadInfo; WeakReference ref = uploadInfoCache.get(); if (ref == null || (uploadInfo = ref.get()) == null || !id.equals(uploadInfo.getId())) { diff --git a/src/main/java/me/desair/tus/server/upload/disk/DiskStorageService.java b/src/main/java/me/desair/tus/server/upload/disk/DiskStorageService.java index db07b043..c84fccb7 100644 --- a/src/main/java/me/desair/tus/server/upload/disk/DiskStorageService.java +++ b/src/main/java/me/desair/tus/server/upload/disk/DiskStorageService.java @@ -168,6 +168,9 @@ public UploadInfo getUploadInfo(String uploadUrl, String ownerKey) throws IOExce @Override public UploadInfo getUploadInfo(UploadId id) throws IOException { + if (id == null) { + return null; + } try { Path infoPath = getInfoPath(id); if (infoPath == null || !Files.exists(infoPath)) { diff --git a/src/main/java/me/desair/tus/server/util/TusServletRequest.java b/src/main/java/me/desair/tus/server/util/TusServletRequest.java index 679f999b..37b0bd49 100644 --- a/src/main/java/me/desair/tus/server/util/TusServletRequest.java +++ b/src/main/java/me/desair/tus/server/util/TusServletRequest.java @@ -85,6 +85,9 @@ public InputStream getContentInputStream() throws IOException { } algorithms.addAll( ChecksumAlgorithm.parseDigestHeader(getHeader(HttpHeader.CONTENT_DIGEST)).keySet()); + algorithms.addAll( + ChecksumAlgorithm.parseDigestHeader(getHeader(HttpHeader.WANT_CONTENT_DIGEST)) + .keySet()); } for (ChecksumAlgorithm algorithm : algorithms) { diff --git a/src/main/java/me/desair/tus/server/util/Utils.java b/src/main/java/me/desair/tus/server/util/Utils.java index 4dea52ab..87637f53 100644 --- a/src/main/java/me/desair/tus/server/util/Utils.java +++ b/src/main/java/me/desair/tus/server/util/Utils.java @@ -22,11 +22,13 @@ import java.util.regex.Pattern; import me.desair.tus.server.HttpHeader; import me.desair.tus.server.HttpMethod; +import me.desair.tus.server.ProtocolVersion; import me.desair.tus.server.checksum.ChecksumAlgorithm; import me.desair.tus.server.upload.UploadInfo; import me.desair.tus.server.upload.UploadStorageService; import org.apache.commons.io.serialization.ValidatingObjectInputStream; import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -228,36 +230,87 @@ public static String getUploadUri(TusServletRequest request, TusServletResponse } /** - * Detects the protocol version for an incoming HTTP request based on request headers and - * configuration. + * Detects the active ProtocolVersion for an incoming HttpServletRequest. * - * @param request The HttpServletRequest + * @param request The current HttpServletRequest * @param supportedProtocolVersion The configured ProtocolVersion setting * @return The detected ProtocolVersion (TUS_1_0_0 or RUFH) */ - public static me.desair.tus.server.ProtocolVersion detectProtocolVersion( - HttpServletRequest request, me.desair.tus.server.ProtocolVersion supportedProtocolVersion) { - if (supportedProtocolVersion == me.desair.tus.server.ProtocolVersion.TUS_1_0_0) { - return me.desair.tus.server.ProtocolVersion.TUS_1_0_0; + public static ProtocolVersion detectProtocolVersion( + HttpServletRequest request, ProtocolVersion supportedProtocolVersion) { + if (supportedProtocolVersion == ProtocolVersion.TUS_1_0_0) { + return ProtocolVersion.TUS_1_0_0; } - if (supportedProtocolVersion == me.desair.tus.server.ProtocolVersion.RUFH) { - return me.desair.tus.server.ProtocolVersion.RUFH; + if (supportedProtocolVersion == ProtocolVersion.RUFH) { + return ProtocolVersion.RUFH; } if (request != null) { - if (request.getHeader(HttpHeader.TUS_RESUMABLE) != null) { - return me.desair.tus.server.ProtocolVersion.TUS_1_0_0; + if (StringUtils.isNotBlank(request.getHeader(HttpHeader.TUS_RESUMABLE))) { + return ProtocolVersion.TUS_1_0_0; + } + if (StringUtils.isNotBlank(request.getHeader(HttpHeader.UPLOAD_OFFSET)) + || StringUtils.isNotBlank(request.getHeader(HttpHeader.UPLOAD_COMPLETE)) + || StringUtils.isNotBlank(request.getHeader(HttpHeader.UPLOAD_DRAFT)) + || StringUtils.isNotBlank(request.getHeader("upload-draft-interop-version")) + || Strings.CS.startsWith( + request.getHeader(HttpHeader.CONTENT_TYPE), HttpHeader.CONTENT_TYPE_PARTIAL_UPLOAD) + || Strings.CS.startsWith( + request.getHeader(HttpHeader.CONTENT_TYPE), "application/offset+octet-stream")) { + return ProtocolVersion.RUFH; } - if (request.getHeader(HttpHeader.UPLOAD_COMPLETE) != null - || request.getHeader(HttpHeader.UPLOAD_DRAFT) != null - || request.getHeader("upload-draft-interop-version") != null - || org.apache.commons.lang3.Strings.CS.startsWith( - request.getHeader(HttpHeader.CONTENT_TYPE), HttpHeader.CONTENT_TYPE_PARTIAL_UPLOAD)) { - return me.desair.tus.server.ProtocolVersion.RUFH; + String method = request.getMethod(); + if (HttpMethod.HEAD.name().equalsIgnoreCase(method) + || HttpMethod.GET.name().equalsIgnoreCase(method) + || HttpMethod.DELETE.name().equalsIgnoreCase(method)) { + return ProtocolVersion.RUFH; } } - return me.desair.tus.server.ProtocolVersion.TUS_1_0_0; + return ProtocolVersion.TUS_1_0_0; + } + + /** + * Determine if the given HTTP servlet request targets the upload creation base URI endpoint. + * + * @param request The HTTP request + * @param uploadStorageService The storage service instance + * @return {@code true} if request targets the base creation endpoint URI; {@code false} otherwise + */ + public static boolean isCreationEndpoint( + HttpServletRequest request, UploadStorageService uploadStorageService) { + if (request == null || uploadStorageService == null) { + return false; + } + String requestUri = request.getRequestURI(); + String baseUri = uploadStorageService.getUploadUri(); + return requestUri != null + && baseUri != null + && (requestUri.equals(baseUri) || requestUri.equals(baseUri + "/")); + } + + /** + * Determine if the given HTTP servlet request target URI represents an existing upload resource. + * + * @param request The HTTP request + * @param uploadStorageService The storage service instance + * @param ownerKey The owner key + * @return {@code true} if the request targets an existing upload resource; {@code false} + * otherwise + * @throws IOException If storage lookup encounters an IO error + */ + public static boolean isExistingUploadResource( + HttpServletRequest request, UploadStorageService uploadStorageService, String ownerKey) + throws IOException { + if (isCreationEndpoint(request, uploadStorageService)) { + return false; + } + String requestUri = request != null ? request.getRequestURI() : null; + UploadInfo existingUpload = + (uploadStorageService != null && requestUri != null) + ? uploadStorageService.getUploadInfo(requestUri, ownerKey) + : null; + return existingUpload != null && !existingUpload.isExpired(); } /** diff --git a/src/test/java/me/desair/tus/server/CoverageGapTest.java b/src/test/java/me/desair/tus/server/CoverageGapTest.java index aac67b99..d297046d 100644 --- a/src/test/java/me/desair/tus/server/CoverageGapTest.java +++ b/src/test/java/me/desair/tus/server/CoverageGapTest.java @@ -7,6 +7,7 @@ import jakarta.servlet.http.HttpServletRequest; import java.io.IOException; +import me.desair.tus.server.exception.InvalidUploadOffsetHeaderException; import me.desair.tus.server.exception.TusException; import me.desair.tus.server.upload.UploadLockingService; import me.desair.tus.server.upload.UploadStorageService; @@ -182,7 +183,7 @@ public void testTusExtensionDefaultMethods() throws Exception { null, null, ProtocolVersion.TUS_1_0_0, - new TusException(400, "Error")); + new InvalidUploadOffsetHeaderException("Error")); assertThat(extension.handleErrorCalled, is(true)); assertThat(pd, nullValue()); } @@ -871,6 +872,7 @@ public void testRufhValidatorsAndErrorHandlerEdgeCases() throws Exception { appReq.setRequestURI("/files/test-id"); appReq.addHeader(HttpHeader.CONTENT_TYPE, HttpHeader.CONTENT_TYPE_PARTIAL_UPLOAD); appReq.addHeader(HttpHeader.UPLOAD_OFFSET, "0"); + appReq.addHeader(HttpHeader.UPLOAD_COMPLETE, "?0"); appReq.setContent("hello".getBytes()); appendVal.validate(HttpMethod.PATCH, appReq, mockStorage, "owner"); @@ -892,7 +894,7 @@ public void testRufhValidatorsAndErrorHandlerEdgeCases() throws Exception { mockStorage, null, "owner", - new TusException(400, "Error")); + new InvalidUploadOffsetHeaderException("Error")); // 4. RufhErrorHandler with null uploadStorageService or null servletRequest errorHandler.process( @@ -902,7 +904,7 @@ public void testRufhValidatorsAndErrorHandlerEdgeCases() throws Exception { null, null, "owner", - new TusException(400, "Error")); + new InvalidUploadOffsetHeaderException("Error")); // 5. RufhCreationValidator minSize == 0 branch org.mockito.Mockito.when(mockStorage.getMinSize()).thenReturn(0L); diff --git a/src/test/java/me/desair/tus/server/HttpProblemDetailsTest.java b/src/test/java/me/desair/tus/server/HttpProblemDetailsTest.java index 005627b8..95538812 100644 --- a/src/test/java/me/desair/tus/server/HttpProblemDetailsTest.java +++ b/src/test/java/me/desair/tus/server/HttpProblemDetailsTest.java @@ -43,6 +43,28 @@ public void testOffsetMismatchProblemDetailsObject() throws Exception { assertThat(response.getStatus(), is(409)); assertThat(response.getHeader(HttpHeader.CONTENT_TYPE), is("application/problem+json")); + assertThat( + response.getContentAsString(), + is( + "{\"type\":\"https://iana.org/assignments/http-problem-types#mismatching-upload-offset\"," + + "\"title\":\"Offset Mismatch\"," + + "\"status\":409," + + "\"detail\":\"The provided Upload-Offset does not match the server's current offset\"," + + "\"expected-offset\":12500000," + + "\"provided-offset\":25000000}")); + } + + @Test + public void testOffsetMismatchProblemDetailsNullProvidedOffset() throws Exception { + HttpProblemDetails problem = HttpProblemDetails.forOffsetMismatch(12500000L, null); + + assertThat(problem.getStatus(), is(409)); + assertThat(problem.getExtraFields().get("expected-offset"), is(12500000L)); + assertThat(problem.getExtraFields().containsKey("provided-offset"), is(false)); + + problem.writeTo(new TusServletResponse(response)); + + assertThat(response.getStatus(), is(409)); assertThat( response.getContentAsString(), is( diff --git a/src/test/java/me/desair/tus/server/ITTusFileUploadService.java b/src/test/java/me/desair/tus/server/ITTusFileUploadService.java index 862082e1..53f6025d 100644 --- a/src/test/java/me/desair/tus/server/ITTusFileUploadService.java +++ b/src/test/java/me/desair/tus/server/ITTusFileUploadService.java @@ -142,7 +142,6 @@ public void testDisableFeature() throws Exception { 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); @@ -262,7 +261,6 @@ public void testProcessCompleteUpload() throws Exception { servletRequest.setRequestURI(location); tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY); - assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0"); assertResponseHeader(HttpHeader.CONTENT_LENGTH, "" + uploadContent.getBytes().length); assertResponseHeader( HttpHeader.UPLOAD_METADATA, "filename d29ybGRfZG9taW5hdGlvbl9wbGFuLnBkZg=="); @@ -325,7 +323,6 @@ public void testProcessZeroByteUpload() throws Exception { servletRequest.setRequestURI(location); tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY); - assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0"); assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0"); assertResponseStatus(HttpServletResponse.SC_OK); assertThat(servletResponse.getContentAsString(), is("")); @@ -382,7 +379,6 @@ public void testTerminateViaHttpRequest() throws Exception { servletRequest.setRequestURI(location); tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY); - assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0"); assertResponseHeader(HttpHeader.CONTENT_LENGTH, "" + uploadContent.getBytes().length); assertResponseHeader( HttpHeader.UPLOAD_METADATA, "filename d29ybGRfZG9taW5hdGlvbl9wbGFuLnBkZg=="); @@ -480,8 +476,7 @@ public void testProcessUploadTwoParts() throws Exception { servletRequest.setRequestURI(location); tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY); - assertResponseStatus(422); - assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0"); + assertResponseStatus(204); assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0"); assertThat(servletResponse.getContentAsString(), is("")); @@ -706,7 +701,6 @@ public void testProcessUploadDeferredLength() throws Exception { tusFileUploadService.process(servletRequest, servletResponse, null); assertResponseStatus(HttpServletResponse.SC_OK); - assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0"); assertResponseHeader(HttpHeader.CONTENT_LENGTH, "" + (part1 + part2 + part3).getBytes().length); assertResponseHeader( HttpHeader.UPLOAD_METADATA, "filename d29ybGRfZG9taW5hdGlvbl9wbGFuLnBkZg=="); @@ -1065,7 +1059,6 @@ public void testConcatenationCompleted() throws Exception { tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY); assertResponseStatus(HttpServletResponse.SC_OK); - assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0"); assertResponseHeader(HttpHeader.CONTENT_LENGTH, "69"); assertResponseHeader( HttpHeader.UPLOAD_METADATA, @@ -1216,8 +1209,7 @@ public void testConcatenationUnfinished() throws Exception { servletRequest.setRequestURI(locationFinal); tusFileUploadService.process(servletRequest, servletResponse); - assertResponseStatus(422); - assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0"); + assertResponseStatus(204); assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0"); assertThat(servletResponse.getContentAsString(), is("")); @@ -1281,7 +1273,6 @@ public void testConcatenationUnfinished() throws Exception { tusFileUploadService.process(servletRequest, servletResponse, null); assertResponseStatus(HttpServletResponse.SC_OK); - assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0"); assertResponseHeader(HttpHeader.CONTENT_LENGTH, "" + (part1 + part2 + part3).getBytes().length); assertResponseHeader(HttpHeader.UPLOAD_METADATA, "filename ZmluYWwucGRm"); assertThat( diff --git a/src/test/java/me/desair/tus/server/ITTusFileUploadServiceCached.java b/src/test/java/me/desair/tus/server/ITTusFileUploadServiceCached.java index 567c14a6..4d619555 100644 --- a/src/test/java/me/desair/tus/server/ITTusFileUploadServiceCached.java +++ b/src/test/java/me/desair/tus/server/ITTusFileUploadServiceCached.java @@ -93,7 +93,6 @@ public void testCachedUploadDifferentKey() throws Exception { servletRequest.setRequestURI(location); tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY); - assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0"); assertResponseHeader(HttpHeader.CONTENT_LENGTH, "" + uploadContent.getBytes().length); assertResponseStatus(HttpServletResponse.SC_OK); assertThat(servletResponse.getContentAsString(), is("This is an upload of someone else")); @@ -104,8 +103,6 @@ public void testCachedUploadDifferentKey() throws Exception { servletRequest.setRequestURI(location); tusFileUploadService.process(servletRequest, servletResponse, "ALTER-EGO"); - assertResponseHeader(HttpHeader.TUS_RESUMABLE, "1.0.0"); - assertResponseHeader(HttpHeader.CONTENT_LENGTH, "0"); assertResponseStatus(HttpServletResponse.SC_NOT_FOUND); } } diff --git a/src/test/java/me/desair/tus/server/TusFileUploadServiceTest.java b/src/test/java/me/desair/tus/server/TusFileUploadServiceTest.java index 5d5532f3..6d7257ae 100644 --- a/src/test/java/me/desair/tus/server/TusFileUploadServiceTest.java +++ b/src/test/java/me/desair/tus/server/TusFileUploadServiceTest.java @@ -344,6 +344,7 @@ public void testProcessTusExceptionRufhOffsetMismatch() throws Exception { mockReq.setRequestURI("/files/test"); mockReq.addHeader(HttpHeader.CONTENT_TYPE, HttpHeader.CONTENT_TYPE_PARTIAL_UPLOAD); mockReq.addHeader(HttpHeader.UPLOAD_OFFSET, "200"); + mockReq.addHeader(HttpHeader.UPLOAD_COMPLETE, "?0"); TusFileUploadService service = new TusFileUploadService() @@ -378,6 +379,7 @@ public void testProcessTusExceptionRufhNullInfoAndHeader() throws Exception { mockReq.setRequestURI("/files/test"); mockReq.addHeader(HttpHeader.CONTENT_TYPE, HttpHeader.CONTENT_TYPE_PARTIAL_UPLOAD); mockReq.addHeader(HttpHeader.UPLOAD_OFFSET, "200"); + mockReq.addHeader(HttpHeader.UPLOAD_COMPLETE, "?0"); TusFileUploadService service = new TusFileUploadService() @@ -410,6 +412,7 @@ public void testProcessTusExceptionRufhNon409() throws Exception { mockReq.setMethod("PATCH"); mockReq.setRequestURI("/files/test"); mockReq.addHeader(HttpHeader.CONTENT_TYPE, "text/plain"); + mockReq.addHeader(HttpHeader.UPLOAD_COMPLETE, "?0"); TusFileUploadService service = new TusFileUploadService() diff --git a/src/test/java/me/desair/tus/server/digest/HttpDigestsPostPutPatchRequestHandlerTest.java b/src/test/java/me/desair/tus/server/digest/HttpDigestsPostPutPatchRequestHandlerTest.java index fdc1a462..928e5555 100644 --- a/src/test/java/me/desair/tus/server/digest/HttpDigestsPostPutPatchRequestHandlerTest.java +++ b/src/test/java/me/desair/tus/server/digest/HttpDigestsPostPutPatchRequestHandlerTest.java @@ -22,6 +22,7 @@ import org.junit.Before; import org.junit.Test; import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; public class HttpDigestsPostPutPatchRequestHandlerTest { @@ -162,4 +163,84 @@ public void testProcessWithReprDigestRequestedAndUploadComplete() throws Excepti servletResponse.getHeader(HttpHeader.REPR_DIGEST), is("sha-256=:LPJNul+wow4m6DsqxbninhsWHlwfp0JecwQzYpOLmCQ=:")); } + + @Test + public void testProcessWithWantContentDigest() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setMethod("POST"); + request.setRequestURI("/files/123"); + request.addHeader(HttpHeader.WANT_CONTENT_DIGEST, "sha-256"); + request.setContent("hello".getBytes(StandardCharsets.UTF_8)); + + TusServletRequest servletRequest = new TusServletRequest(request); + byte[] buffer = new byte[100]; + servletRequest.getContentInputStream().read(buffer); + + MockHttpServletResponse mockResp = new MockHttpServletResponse(); + TusServletResponse servletResponse = new TusServletResponse(mockResp); + + handler.process( + HttpMethod.POST, + servletRequest, + servletResponse, + uploadStorageService, + uploadLockingService, + "owner", + null); + + assertThat( + mockResp.getHeader(HttpHeader.CONTENT_DIGEST), + is("sha-256=:LPJNul+wow4m6DsqxbninhsWHlwfp0JecwQzYpOLmCQ=:")); + } + + @Test + public void testProcessWithWantContentDigestUnknownAlgorithm() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setMethod("POST"); + request.setRequestURI("/files/123"); + request.addHeader(HttpHeader.WANT_CONTENT_DIGEST, "unknown-alg=1"); + request.setContent("hello".getBytes(StandardCharsets.UTF_8)); + + TusServletRequest servletRequest = new TusServletRequest(request); + MockHttpServletResponse mockResp = new MockHttpServletResponse(); + TusServletResponse servletResponse = new TusServletResponse(mockResp); + + handler.process( + HttpMethod.POST, + servletRequest, + servletResponse, + uploadStorageService, + uploadLockingService, + "owner", + null); + + assertThat( + mockResp.getHeader(HttpHeader.CONTENT_DIGEST), org.hamcrest.CoreMatchers.nullValue()); + } + + @Test + public void testProcessWithWantContentDigestNoBodyRead() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setMethod("POST"); + request.setRequestURI("/files/123"); + request.addHeader(HttpHeader.WANT_CONTENT_DIGEST, "sha-256"); + request.setContent("hello".getBytes(StandardCharsets.UTF_8)); + + // Notice: we do NOT read content input stream, so calculatedVal is null + TusServletRequest servletRequest = new TusServletRequest(request); + MockHttpServletResponse mockResp = new MockHttpServletResponse(); + TusServletResponse servletResponse = new TusServletResponse(mockResp); + + handler.process( + HttpMethod.POST, + servletRequest, + servletResponse, + uploadStorageService, + uploadLockingService, + "owner", + null); + + assertThat( + mockResp.getHeader(HttpHeader.CONTENT_DIGEST), org.hamcrest.CoreMatchers.nullValue()); + } } diff --git a/src/test/java/me/desair/tus/server/download/DownloadGetRequestHandlerTest.java b/src/test/java/me/desair/tus/server/download/DownloadGetRequestHandlerTest.java index a0655e51..062fa751 100644 --- a/src/test/java/me/desair/tus/server/download/DownloadGetRequestHandlerTest.java +++ b/src/test/java/me/desair/tus/server/download/DownloadGetRequestHandlerTest.java @@ -15,7 +15,7 @@ import me.desair.tus.server.HttpHeader; import me.desair.tus.server.HttpMethod; import me.desair.tus.server.ProtocolVersion; -import me.desair.tus.server.exception.UploadInProgressException; +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; @@ -60,8 +60,8 @@ public void supports() throws Exception { assertThat(handler.supports(HttpMethod.GET, ProtocolVersion.TUS_1_0_0), is(true)); assertThat(handler.supports(HttpMethod.GET, ProtocolVersion.RUFH), is(true)); - assertThat(handler.supports(HttpMethod.GET, ProtocolVersion.AUTO), is(false)); - assertThat(handler.supports(HttpMethod.GET, null), is(false)); + assertThat(handler.supports(HttpMethod.GET, ProtocolVersion.AUTO), is(true)); + assertThat(handler.supports(HttpMethod.GET, null), is(true)); assertThat(handler.supports(HttpMethod.POST, ProtocolVersion.TUS_1_0_0), is(false)); assertThat(handler.supports(null, ProtocolVersion.TUS_1_0_0), is(false)); } @@ -134,7 +134,7 @@ public void testWithCompletedUploadWithoutMetadata() throws Exception { assertThat(servletResponse.getHeader(HttpHeader.CONTENT_TYPE), is("application/octet-stream")); } - @Test(expected = UploadInProgressException.class) + @Test public void testWithInProgressUpload() throws Exception { final UploadId id = new UploadId(UUID.randomUUID()); @@ -152,9 +152,11 @@ public void testWithInProgressUpload() throws Exception { new TusServletResponse(servletResponse), uploadStorageService, null); + + assertThat(servletResponse.getStatus(), is(204)); } - @Test(expected = UploadInProgressException.class) + @Test(expected = UploadNotFoundException.class) public void testWithUnknownUpload() throws Exception { when(uploadStorageService.getUploadInfo(nullable(String.class), nullable(String.class))) .thenReturn(null); @@ -170,4 +172,20 @@ public void testWithUnknownUpload() throws Exception { .copyUploadTo(any(UploadInfo.class), any(OutputStream.class)); assertThat(servletResponse.getStatus(), is(HttpServletResponse.SC_NO_CONTENT)); } + + @Test(expected = UploadNotFoundException.class) + public void testWithExpiredUpload() throws Exception { + UploadInfo info = new UploadInfo(); + info.setId(new UploadId(UUID.randomUUID())); + info.setExpirationTimestamp(System.currentTimeMillis() - 1000L); + when(uploadStorageService.getUploadInfo(nullable(String.class), nullable(String.class))) + .thenReturn(info); + + handler.process( + HttpMethod.GET, + new TusServletRequest(servletRequest), + new TusServletResponse(servletResponse), + uploadStorageService, + null); + } } diff --git a/src/test/java/me/desair/tus/server/download/DownloadUploadMetadataHandlerTest.java b/src/test/java/me/desair/tus/server/download/DownloadUploadMetadataHandlerTest.java index be625667..8794d43c 100644 --- a/src/test/java/me/desair/tus/server/download/DownloadUploadMetadataHandlerTest.java +++ b/src/test/java/me/desair/tus/server/download/DownloadUploadMetadataHandlerTest.java @@ -45,7 +45,7 @@ public void setUp() { public void supports() { assertThat(handler.supports(HttpMethod.GET), is(true)); assertThat(handler.supports(HttpMethod.GET, ProtocolVersion.TUS_1_0_0), is(true)); - assertThat(handler.supports(HttpMethod.GET, ProtocolVersion.RUFH), is(false)); + assertThat(handler.supports(HttpMethod.GET, ProtocolVersion.RUFH), is(true)); assertThat(handler.supports(HttpMethod.POST, ProtocolVersion.TUS_1_0_0), is(false)); } diff --git a/src/test/java/me/desair/tus/server/rufh/DownloadProtocolRufhTest.java b/src/test/java/me/desair/tus/server/rufh/DownloadProtocolRufhTest.java index 4ea790ab..f9bd802e 100644 --- a/src/test/java/me/desair/tus/server/rufh/DownloadProtocolRufhTest.java +++ b/src/test/java/me/desair/tus/server/rufh/DownloadProtocolRufhTest.java @@ -160,8 +160,11 @@ public void testDownloadInProgressRufhUpload() throws Exception { servletRequest.setRequestURI(uploadLocation); tusFileUploadService.process(servletRequest, servletResponse, OWNER_KEY); - // Should return 422 Unprocessable Entity - assertThat(servletResponse.getStatus(), is(422)); + // In RUFH §4.3, GET on an in-progress upload resource serves as an offset retrieval request + // (204 No Content) + assertThat(servletResponse.getStatus(), is(204)); + assertThat(servletResponse.getHeader(HttpHeader.UPLOAD_OFFSET), is("0")); + assertThat(servletResponse.getHeader(HttpHeader.UPLOAD_COMPLETE), is("?0")); } @Test diff --git a/src/test/java/me/desair/tus/server/rufh/RufhProtocolAppendTest.java b/src/test/java/me/desair/tus/server/rufh/RufhProtocolAppendTest.java index b1b66ae0..9b105976 100644 --- a/src/test/java/me/desair/tus/server/rufh/RufhProtocolAppendTest.java +++ b/src/test/java/me/desair/tus/server/rufh/RufhProtocolAppendTest.java @@ -119,6 +119,50 @@ public void testAppendExceedingUploadLengthRejected() throws Exception { HttpMethod.PATCH, request, storageService, lockingService, null, ProtocolVersion.RUFH); } + /** + * Section 4.4.2 (Append Response - Resource Invalidation): "If representation data is received + * with an offset that exceeds the representation's length, the server MUST prevent the offset + * from exceeding the representation's length and MUST invalidate the upload resource." + */ + @Test + public void testAppendExceedingLengthInvalidatesResource() throws Exception { + request.setMethod("PATCH"); + request.setRequestURI("/files/test-id"); + request.addHeader(HttpHeader.CONTENT_TYPE, HttpHeader.CONTENT_TYPE_PARTIAL_UPLOAD); + request.addHeader(HttpHeader.UPLOAD_OFFSET, "4990"); + request.addHeader(HttpHeader.UPLOAD_COMPLETE, "?0"); + request.setContent("This content is 30 bytes long".getBytes()); // 4990 + 30 = 5020 > 5000 + + UploadInfo info = new UploadInfo(); + info.setId(new UploadId("test-id")); + info.setOffset(4990L); + info.setLength(5000L); + + when(storageService.getUploadInfo("/files/test-id", null)).thenReturn(info); + + try { + protocol.validate( + HttpMethod.PATCH, request, storageService, lockingService, null, ProtocolVersion.RUFH); + } catch (TusException expected) { + // Expected exception + } + + verify(storageService).terminateUpload(info); + } + + /** + * Section 4.4.2 (Append Response - Interim Responses): "Interim responses (104) MAY be generated + * by the server during append requests. These interim responses MUST NOT include the Location + * header field." + */ + @Test + public void testAppendInterim104OmitsLocationHeader() { + String interimFrame = + me.desair.tus.server.rufh.util.RufhInterimResponseUtil.getRawInterimResponseForAppend(500L); + assertThat(interimFrame.contains("Location:"), is(false)); + assertThat(interimFrame.contains("Upload-Offset: 500"), is(true)); + } + /** * Section 7.2 (Completed Upload) of draft-12: "This section defines the * 'https://iana.org/assignments/http-problem-types#completed-upload' problem type. A server can @@ -322,6 +366,7 @@ public void testUploadAppendWithinMaxAppendSizeSuccess() throws Exception { request.setRequestURI("/files/test-id"); request.addHeader(HttpHeader.CONTENT_TYPE, HttpHeader.CONTENT_TYPE_PARTIAL_UPLOAD); request.addHeader(HttpHeader.UPLOAD_OFFSET, "1000"); + request.addHeader(HttpHeader.UPLOAD_COMPLETE, "?0"); request.setContent("short payload".getBytes()); UploadInfo info = new UploadInfo(); @@ -441,4 +486,25 @@ public void testAppendValidMinAppendSize() throws Exception { protocol.validate( HttpMethod.PATCH, request, storageService, lockingService, null, ProtocolVersion.RUFH); } + + /** + * Section 4.4.1 (Append Request): "The client MUST include the Upload-Complete header field in + * every PATCH append request." + */ + @Test(expected = TusException.class) + public void testUploadAppendMissingUploadCompleteHeaderThrows400() throws Exception { + request.setMethod("PATCH"); + request.setRequestURI("/files/test-id"); + request.addHeader(HttpHeader.CONTENT_TYPE, HttpHeader.CONTENT_TYPE_PARTIAL_UPLOAD); + request.addHeader(HttpHeader.UPLOAD_OFFSET, "1000"); + + UploadInfo info = new UploadInfo(); + info.setId(new UploadId("test-id")); + info.setOffset(1000L); + + when(storageService.getUploadInfo("/files/test-id", null)).thenReturn(info); + + protocol.validate( + HttpMethod.PATCH, request, storageService, lockingService, null, ProtocolVersion.RUFH); + } } diff --git a/src/test/java/me/desair/tus/server/rufh/RufhProtocolCreationTest.java b/src/test/java/me/desair/tus/server/rufh/RufhProtocolCreationTest.java index 0aff0120..abd1dea7 100644 --- a/src/test/java/me/desair/tus/server/rufh/RufhProtocolCreationTest.java +++ b/src/test/java/me/desair/tus/server/rufh/RufhProtocolCreationTest.java @@ -146,6 +146,36 @@ public void testInconsistentUploadLengthValidation() throws Exception { HttpMethod.POST, request, storageService, lockingService, null, ProtocolVersion.RUFH); } + /** + * Section 7.2 (Inconsistent Length Response): "The server responds with a 400 (Bad Request) + * status code and the Upload-Complete: ?0 header field." + */ + @Test + public void testInconsistentUploadLengthIncludesUploadCompleteFalse() throws Exception { + request.setMethod("POST"); + request.setRequestURI("/files"); + request.addHeader(HttpHeader.UPLOAD_LENGTH, "1000"); + request.addHeader(HttpHeader.UPLOAD_COMPLETE, "?1"); + request.setContent("Hello World".getBytes()); + + me.desair.tus.server.rufh.handler.RufhErrorHandler errorHandler = + new me.desair.tus.server.rufh.handler.RufhErrorHandler(); + me.desair.tus.server.exception.InconsistentUploadLengthException ex = + new me.desair.tus.server.exception.InconsistentUploadLengthException("Length mismatch"); + + TusServletResponse servletResponse = new TusServletResponse(response); + errorHandler.process( + HttpMethod.POST, + new TusServletRequest(request), + servletResponse, + storageService, + lockingService, + null, + ex); + + assertThat(response.getHeader(HttpHeader.UPLOAD_COMPLETE), is("?0")); + } + /** * Section 4.2.1 & 4.2.2 (Upload Creation without Upload-Length): "If the Upload-Complete header * field is set to true, but Upload-Length is omitted, the server determines the length from the @@ -331,4 +361,114 @@ public void testUploadCreationValidMinSizeAndMinAppendSize() throws Exception { protocol.validate( HttpMethod.POST, request, storageService, lockingService, null, ProtocolVersion.RUFH); } + + /** + * Section 4.2.1 (Upload Creation): "All request methods allowing content can be used to start a + * resumable upload (e.g. POST, PUT, PATCH)." + */ + @Test + public void testUploadCreationPutMethod() throws Exception { + request.setMethod("PUT"); + request.setRequestURI("/files"); + request.addHeader(HttpHeader.UPLOAD_LENGTH, "100"); + request.addHeader(HttpHeader.UPLOAD_COMPLETE, "?1"); + + UploadInfo info = new UploadInfo(); + info.setLength(100L); + info.setOffset(0L); + info.setId(new UuidUploadIdFactory().createId()); + + when(storageService.create(any(UploadInfo.class), nullable(String.class))).thenReturn(info); + + protocol.validate( + HttpMethod.PUT, request, storageService, lockingService, null, ProtocolVersion.RUFH); + protocol.process( + HttpMethod.PUT, + new TusServletRequest(request, true), + new TusServletResponse(response), + storageService, + lockingService, + null, + ProtocolVersion.RUFH); + + assertThat(response.getStatus(), is(200)); + } + + /** Section 4.2.1 (Upload Creation): Creation using PATCH method on creation endpoint. */ + @Test + public void testUploadCreationPatchMethod() throws Exception { + request.setMethod("PATCH"); + request.setRequestURI("/files"); + request.addHeader(HttpHeader.UPLOAD_LENGTH, "100"); + request.addHeader(HttpHeader.UPLOAD_COMPLETE, "?1"); + + UploadInfo info = new UploadInfo(); + info.setLength(100L); + info.setOffset(0L); + info.setId(new UuidUploadIdFactory().createId()); + + when(storageService.create(any(UploadInfo.class), nullable(String.class))).thenReturn(info); + + protocol.validate( + HttpMethod.PATCH, request, storageService, lockingService, null, ProtocolVersion.RUFH); + protocol.process( + HttpMethod.PATCH, + new TusServletRequest(request, true), + new TusServletResponse(response), + storageService, + lockingService, + null, + ProtocolVersion.RUFH); + + assertThat(response.getStatus(), is(200)); + } + + @Test + public void testUploadCreationWithPreCreatedUploadInfoAndLength() throws Exception { + request.setMethod("POST"); + request.setRequestURI("/files"); + request.addHeader(HttpHeader.UPLOAD_LENGTH, "500"); + request.addHeader(HttpHeader.UPLOAD_COMPLETE, "?0"); + + UploadInfo preCreated = new UploadInfo(); + preCreated.setId(new UuidUploadIdFactory().createId()); + request.setAttribute("me.desair.tus.preCreatedUploadInfo", preCreated); + + when(storageService.append(any(UploadInfo.class), any())).thenReturn(preCreated); + + protocol.process( + HttpMethod.POST, + new TusServletRequest(request, true), + new TusServletResponse(response), + storageService, + lockingService, + null, + ProtocolVersion.RUFH); + + assertThat(response.getStatus(), is(201)); + } + + @Test + public void testUploadCreationWithPreCreatedUploadInfoWithoutLength() throws Exception { + request.setMethod("POST"); + request.setRequestURI("/files"); + request.addHeader(HttpHeader.UPLOAD_COMPLETE, "?0"); + + UploadInfo preCreated = new UploadInfo(); + preCreated.setId(new UuidUploadIdFactory().createId()); + request.setAttribute("me.desair.tus.preCreatedUploadInfo", preCreated); + + when(storageService.append(any(UploadInfo.class), any())).thenReturn(preCreated); + + protocol.process( + HttpMethod.POST, + new TusServletRequest(request, true), + new TusServletResponse(response), + storageService, + lockingService, + null, + ProtocolVersion.RUFH); + + assertThat(response.getStatus(), is(201)); + } } diff --git a/src/test/java/me/desair/tus/server/rufh/handler/RufhDeleteRequestHandlerTest.java b/src/test/java/me/desair/tus/server/rufh/handler/RufhDeleteRequestHandlerTest.java index 88508499..024993b2 100644 --- a/src/test/java/me/desair/tus/server/rufh/handler/RufhDeleteRequestHandlerTest.java +++ b/src/test/java/me/desair/tus/server/rufh/handler/RufhDeleteRequestHandlerTest.java @@ -69,8 +69,8 @@ public void testProcessDeleteRequest() throws Exception { verify(storageService).terminateUpload(info); } - @Test - public void testProcessWithNullUploadInfo() throws Exception { + @Test(expected = me.desair.tus.server.exception.TusException.class) + public void testProcessWithNullUploadInfoThrows404() throws Exception { request.setRequestURI("/files/delete-id"); when(storageService.getUploadInfo("/files/delete-id", "owner")).thenReturn(null); @@ -82,7 +82,5 @@ public void testProcessWithNullUploadInfo() throws Exception { null, "owner", null); - - assertThat(response.getStatus(), is(204)); } } diff --git a/src/test/java/me/desair/tus/server/rufh/handler/RufhErrorHandlerTest.java b/src/test/java/me/desair/tus/server/rufh/handler/RufhErrorHandlerTest.java index 6a2199f7..66362a5e 100644 --- a/src/test/java/me/desair/tus/server/rufh/handler/RufhErrorHandlerTest.java +++ b/src/test/java/me/desair/tus/server/rufh/handler/RufhErrorHandlerTest.java @@ -83,6 +83,8 @@ public void testProcessErrorHandler409Mismatch() throws Exception { } assertThat(response.getStatus(), is(409)); + assertThat(response.getHeader(HttpHeader.UPLOAD_OFFSET), is("1000")); + assertThat(response.getHeader(HttpHeader.UPLOAD_COMPLETE), is("?0")); assertThat(response.getHeader(HttpHeader.CONTENT_TYPE), is("application/problem+json")); assertThat(response.getContentAsString(), containsString("\"expected-offset\":1000")); } diff --git a/src/test/java/me/desair/tus/server/rufh/handler/RufhHeadGetRequestHandlerTest.java b/src/test/java/me/desair/tus/server/rufh/handler/RufhHeadGetRequestHandlerTest.java index 56e9019d..6a865fb3 100644 --- a/src/test/java/me/desair/tus/server/rufh/handler/RufhHeadGetRequestHandlerTest.java +++ b/src/test/java/me/desair/tus/server/rufh/handler/RufhHeadGetRequestHandlerTest.java @@ -8,6 +8,7 @@ import me.desair.tus.server.HttpHeader; import me.desair.tus.server.HttpMethod; +import me.desair.tus.server.exception.TusException; import me.desair.tus.server.upload.UploadId; import me.desair.tus.server.upload.UploadInfo; import me.desair.tus.server.upload.UploadStorageService; @@ -128,41 +129,39 @@ public void testProcessCompletedHeadRequest() throws Exception { assertThat(response.getHeader(HttpHeader.UPLOAD_COMPLETE), is("?1")); } - @Test - public void testProcessNullUploadInfo() throws Exception { + @Test(expected = TusException.class) + public void testProcessNullUploadInfoThrows404() throws Exception { request.setRequestURI("/files/null-id"); + when(storageService.getUploadUri()).thenReturn("/files"); when(storageService.getUploadInfo("/files/null-id", "owner")).thenReturn(null); - assertThat( - handler.process( - HttpMethod.HEAD, - new TusServletRequest(request), - new TusServletResponse(response), - storageService, - null, - "owner", - null), - org.hamcrest.CoreMatchers.nullValue()); + handler.process( + HttpMethod.HEAD, + new TusServletRequest(request), + new TusServletResponse(response), + storageService, + null, + "owner", + null); } - @Test - public void testProcessExpiredUploadInfo() throws Exception { + @Test(expected = TusException.class) + public void testProcessExpiredUploadInfoThrows404() throws Exception { request.setRequestURI("/files/expired-id"); + when(storageService.getUploadUri()).thenReturn("/files"); UploadInfo info = new UploadInfo(); info.setId(new UploadId("expired-id")); info.setExpirationTimestamp(System.currentTimeMillis() - 1000L); // Expired when(storageService.getUploadInfo("/files/expired-id", "owner")).thenReturn(info); - assertThat( - handler.process( - HttpMethod.HEAD, - new TusServletRequest(request), - new TusServletResponse(response), - storageService, - null, - "owner", - null), - org.hamcrest.CoreMatchers.nullValue()); + handler.process( + HttpMethod.HEAD, + new TusServletRequest(request), + new TusServletResponse(response), + storageService, + null, + "owner", + null); } @Test @@ -188,4 +187,44 @@ public void testProcessUploadInfoWithoutLength() throws Exception { assertThat(response.getHeader(HttpHeader.UPLOAD_COMPLETE), is("?0")); assertThat(response.getHeader(HttpHeader.UPLOAD_LENGTH), org.hamcrest.CoreMatchers.nullValue()); } + + @Test + public void testProcessCreationEndpointWithNullUploadInfoReturnsNull() throws Exception { + request.setRequestURI("/files"); + when(storageService.getUploadUri()).thenReturn("/files"); + when(storageService.getUploadInfo("/files", "owner")).thenReturn(null); + + Object result = + handler.process( + HttpMethod.HEAD, + new TusServletRequest(request), + new TusServletResponse(response), + storageService, + null, + "owner", + null); + + assertThat(result, org.hamcrest.CoreMatchers.nullValue()); + } + + @Test + public void testProcessCreationEndpointWithExpiredUploadInfoReturnsNull() throws Exception { + request.setRequestURI("/files"); + when(storageService.getUploadUri()).thenReturn("/files"); + UploadInfo expiredInfo = new UploadInfo(); + expiredInfo.setExpirationTimestamp(System.currentTimeMillis() - 1000L); + when(storageService.getUploadInfo("/files", "owner")).thenReturn(expiredInfo); + + Object result = + handler.process( + HttpMethod.HEAD, + new TusServletRequest(request), + new TusServletResponse(response), + storageService, + null, + "owner", + null); + + assertThat(result, org.hamcrest.CoreMatchers.nullValue()); + } } diff --git a/src/test/java/me/desair/tus/server/rufh/handler/RufhUploadLimitHeaderRequestHandlerTest.java b/src/test/java/me/desair/tus/server/rufh/handler/RufhUploadLimitHeaderRequestHandlerTest.java index ccc881ad..9e78e428 100644 --- a/src/test/java/me/desair/tus/server/rufh/handler/RufhUploadLimitHeaderRequestHandlerTest.java +++ b/src/test/java/me/desair/tus/server/rufh/handler/RufhUploadLimitHeaderRequestHandlerTest.java @@ -46,6 +46,7 @@ public void supports() { assertThat(handler.supports(HttpMethod.DELETE), is(false)); assertThat(handler.supports(HttpMethod.POST, ProtocolVersion.RUFH), is(true)); + assertThat(handler.supports(HttpMethod.OPTIONS, ProtocolVersion.TUS_1_0_0), is(true)); assertThat(handler.supports(HttpMethod.POST, ProtocolVersion.TUS_1_0_0), is(false)); assertThat(handler.supports(HttpMethod.DELETE, ProtocolVersion.RUFH), is(false)); } @@ -165,4 +166,45 @@ public void testProcessMaxAgeFromStorageService() throws Exception { assertThat(uploadLimit, is(notNullValue())); assertThat(uploadLimit.contains("max-age=120"), is(true)); } + + @Test + public void testProcessWithLocationHeader() throws Exception { + when(uploadStorageService.getMaxUploadSize()).thenReturn(10000L); + when(uploadStorageService.getUploadInfo("/files/123", null)).thenReturn(null); + + servletResponse.setHeader(HttpHeader.LOCATION, "/files/123"); + TusServletResponse tusResponse = new TusServletResponse(servletResponse); + + handler.process(HttpMethod.POST, null, tusResponse, uploadStorageService, null); + + assertThat(tusResponse.getHeader(HttpHeader.UPLOAD_LIMIT), is("max-size=10000")); + } + + @Test + public void testProcessGetUploadInfoThrowsException() throws Exception { + when(uploadStorageService.getMaxUploadSize()).thenReturn(10000L); + when(uploadStorageService.getUploadInfo(nullable(String.class), nullable(String.class))) + .thenThrow(new RuntimeException("Storage failure")); + + TusServletResponse tusResponse = new TusServletResponse(servletResponse); + + handler.process( + HttpMethod.POST, + new TusServletRequest(servletRequest), + tusResponse, + uploadStorageService, + null); + + assertThat(tusResponse.getHeader(HttpHeader.UPLOAD_LIMIT), is("max-size=10000")); + } + + @Test + public void testProcessWithBlankUriAndNullRequest() throws Exception { + when(uploadStorageService.getMaxUploadSize()).thenReturn(10000L); + TusServletResponse tusResponse = new TusServletResponse(servletResponse); + + handler.process(HttpMethod.POST, null, tusResponse, uploadStorageService, null); + + assertThat(tusResponse.getHeader(HttpHeader.UPLOAD_LIMIT), is("max-size=10000")); + } } diff --git a/src/test/java/me/desair/tus/server/rufh/util/RufhInterimResponseUtilTest.java b/src/test/java/me/desair/tus/server/rufh/util/RufhInterimResponseUtilTest.java index eaf15737..b0276d00 100644 --- a/src/test/java/me/desair/tus/server/rufh/util/RufhInterimResponseUtilTest.java +++ b/src/test/java/me/desair/tus/server/rufh/util/RufhInterimResponseUtilTest.java @@ -84,7 +84,8 @@ public void testGetRawInterimResponseWithExistingUploadAndStorageException() thr String raw = RufhInterimResponseUtil.getRawInterimResponse(request, mockStorage, "owner"); assertNotNull(raw); - assertTrue(raw.contains("Location: /files/existing-123")); + assertTrue(raw.contains("Upload-Offset: 0")); + org.junit.Assert.assertFalse(raw.contains("Location:")); // Storage exception returns null org.mockito.Mockito.when(mockStorage.getUploadInfo("/files/existing-123", "owner")) @@ -116,4 +117,76 @@ public void testGetRawInterimResponseWithExistingUploadNotFoundAndNullHost() thr assertNotNull(raw); assertTrue(raw.contains("Location: /files/not-found-123/created-456")); } + + @Test + public void testGetRawInterimResponseWithAbsoluteUploadUri() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setMethod("POST"); + request.setRequestURI("/files"); + + me.desair.tus.server.upload.UploadStorageService mockStorage = + org.mockito.Mockito.mock(me.desair.tus.server.upload.UploadStorageService.class); + me.desair.tus.server.upload.UploadInfo created = new me.desair.tus.server.upload.UploadInfo(); + created.setId(new me.desair.tus.server.upload.UploadId("123")); + + org.mockito.Mockito.when(mockStorage.getUploadUri()) + .thenReturn("https://custom.domain.com/files"); + org.mockito.Mockito.when( + mockStorage.create( + org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.eq("owner"))) + .thenReturn(created); + + String raw = RufhInterimResponseUtil.getRawInterimResponse(request, mockStorage, "owner"); + assertNotNull(raw); + assertTrue(raw.contains("Location: https://custom.domain.com/files/123")); + } + + @Test + public void testGetRawInterimResponseWithNullUploadOffset() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setMethod("PATCH"); + request.setRequestURI("/files/null-offset"); + request.addHeader(HttpHeader.UPLOAD_COMPLETE, "?0"); + + me.desair.tus.server.upload.UploadStorageService mockStorage = + org.mockito.Mockito.mock(me.desair.tus.server.upload.UploadStorageService.class); + me.desair.tus.server.upload.UploadInfo info = new me.desair.tus.server.upload.UploadInfo(); + info.setOffset(null); + + org.mockito.Mockito.when(mockStorage.getUploadInfo("/files/null-offset", "owner")) + .thenReturn(info); + + String raw = RufhInterimResponseUtil.getRawInterimResponse(request, mockStorage, "owner"); + assertNotNull(raw); + assertTrue(raw.contains("Upload-Offset: 0")); + } + + @Test + public void testGetRawInterimResponseWithNullMethodAndPartialSchemeHost() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setMethod(null); + request.setRequestURI("/files"); + + me.desair.tus.server.upload.UploadStorageService mockStorage = + org.mockito.Mockito.mock(me.desair.tus.server.upload.UploadStorageService.class); + + assertNull(RufhInterimResponseUtil.getRawInterimResponse(request, mockStorage, "owner")); + + // Scheme set, host null + MockHttpServletRequest request2 = new MockHttpServletRequest(); + request2.setMethod("POST"); + request2.setRequestURI("/files"); + request2.setScheme("https"); + + me.desair.tus.server.upload.UploadInfo created = new me.desair.tus.server.upload.UploadInfo(); + created.setId(new me.desair.tus.server.upload.UploadId("789")); + org.mockito.Mockito.when( + mockStorage.create( + org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.eq("owner"))) + .thenReturn(created); + + String raw = RufhInterimResponseUtil.getRawInterimResponse(request2, mockStorage, "owner"); + assertNotNull(raw); + assertTrue(raw.contains("Location: /files/789")); + } } diff --git a/src/test/java/me/desair/tus/server/rufh/validation/RufhAppendValidatorTest.java b/src/test/java/me/desair/tus/server/rufh/validation/RufhAppendValidatorTest.java index d52a8450..15135083 100644 --- a/src/test/java/me/desair/tus/server/rufh/validation/RufhAppendValidatorTest.java +++ b/src/test/java/me/desair/tus/server/rufh/validation/RufhAppendValidatorTest.java @@ -28,6 +28,7 @@ public class RufhAppendValidatorTest { public void setUp() { validator = new RufhAppendValidator(); request = new MockHttpServletRequest(); + request.addHeader(HttpHeader.UPLOAD_COMPLETE, "?0"); } @Test @@ -104,11 +105,11 @@ public void testValidateValidAppendRequest() throws Exception { validator.validate(HttpMethod.PATCH, request, storageService, "owner"); } - @Test + @Test(expected = TusException.class) public void testValidateUploadInfoNull() throws Exception { + when(storageService.getUploadUri()).thenReturn("/files"); request.setRequestURI("/files/does-not-exist"); when(storageService.getUploadInfo("/files/does-not-exist", "owner")).thenReturn(null); - // Should return early and not throw any exception validator.validate(HttpMethod.PATCH, request, storageService, "owner"); } @@ -210,4 +211,78 @@ public void testValidateMatchingUploadLength() throws Exception { validator.validate(HttpMethod.PATCH, request, storageService, "owner"); } + + @Test(expected = TusException.class) + public void testValidateMissingUploadCompleteHeader() throws Exception { + MockHttpServletRequest missingCompleteReq = new MockHttpServletRequest(); + missingCompleteReq.setRequestURI("/files/exists"); + missingCompleteReq.addHeader(HttpHeader.CONTENT_TYPE, HttpHeader.CONTENT_TYPE_PARTIAL_UPLOAD); + missingCompleteReq.addHeader(HttpHeader.UPLOAD_OFFSET, "1000"); + + UploadInfo info = new UploadInfo(); + info.setLength(5000L); + info.setOffset(1000L); + when(storageService.getUploadInfo("/files/exists", "owner")).thenReturn(info); + + validator.validate(HttpMethod.PATCH, missingCompleteReq, storageService, "owner"); + } + + /** + * Section 4.4.2 (Server Behavior - Offset Exceeding Length): "the server MUST prevent the offset + * from exceeding the representation's length by rejecting the request once the offset exceeds the + * length, marking the upload resource invalid and rejecting any further interaction with it." + */ + @Test(expected = TusException.class) + public void testValidateExceedingUploadLengthInvalidatesResource() throws Exception { + request.setRequestURI("/files/exists"); + request.addHeader(HttpHeader.CONTENT_TYPE, HttpHeader.CONTENT_TYPE_PARTIAL_UPLOAD); + request.addHeader(HttpHeader.UPLOAD_OFFSET, "1000"); + request.setContent(new byte[5000]); // 1000 + 5000 = 6000 > 5000 declared length + + UploadInfo info = new UploadInfo(); + info.setLength(5000L); + info.setOffset(1000L); + when(storageService.getUploadInfo("/files/exists", "owner")).thenReturn(info); + + try { + validator.validate(HttpMethod.PATCH, request, storageService, "owner"); + } finally { + org.mockito.Mockito.verify(storageService).terminateUpload(info); + } + } + + @Test(expected = TusException.class) + public void testValidateCompletedUploadTerminateThrowsException() throws Exception { + request.setRequestURI("/files/exists"); + request.addHeader(HttpHeader.CONTENT_TYPE, HttpHeader.CONTENT_TYPE_PARTIAL_UPLOAD); + request.addHeader(HttpHeader.UPLOAD_OFFSET, "5000"); + + UploadInfo info = new UploadInfo(); + info.setLength(5000L); + info.setOffset(5000L); // Completed + when(storageService.getUploadInfo("/files/exists", "owner")).thenReturn(info); + org.mockito.Mockito.doThrow(new RuntimeException("Terminate failed")) + .when(storageService) + .terminateUpload(info); + + validator.validate(HttpMethod.PATCH, request, storageService, "owner"); + } + + @Test(expected = TusException.class) + public void testValidateExceedingLengthTerminateThrowsException() throws Exception { + request.setRequestURI("/files/exists"); + request.addHeader(HttpHeader.CONTENT_TYPE, HttpHeader.CONTENT_TYPE_PARTIAL_UPLOAD); + request.addHeader(HttpHeader.UPLOAD_OFFSET, "1000"); + request.setContent(new byte[5000]); + + UploadInfo info = new UploadInfo(); + info.setLength(5000L); + info.setOffset(1000L); + when(storageService.getUploadInfo("/files/exists", "owner")).thenReturn(info); + org.mockito.Mockito.doThrow(new RuntimeException("Terminate failed")) + .when(storageService) + .terminateUpload(info); + + validator.validate(HttpMethod.PATCH, request, storageService, "owner"); + } } diff --git a/src/test/java/me/desair/tus/server/rufh/validation/RufhCreationValidatorTest.java b/src/test/java/me/desair/tus/server/rufh/validation/RufhCreationValidatorTest.java index 6476694d..a9104580 100644 --- a/src/test/java/me/desair/tus/server/rufh/validation/RufhCreationValidatorTest.java +++ b/src/test/java/me/desair/tus/server/rufh/validation/RufhCreationValidatorTest.java @@ -147,4 +147,14 @@ public long getContentLengthLong() { negativeRequest.addHeader(HttpHeader.UPLOAD_COMPLETE, "?1"); validator.validate(HttpMethod.POST, negativeRequest, storageService, null); } + + @Test + public void testValidatePatchOnExistingResourceSkipped() throws Exception { + request.setRequestURI("/files/existing-id"); + when(storageService.getUploadUri()).thenReturn("/files"); + me.desair.tus.server.upload.UploadInfo existing = new me.desair.tus.server.upload.UploadInfo(); + when(storageService.getUploadInfo("/files/existing-id", "owner")).thenReturn(existing); + + validator.validate(HttpMethod.PATCH, request, storageService, "owner"); + } } diff --git a/src/test/java/me/desair/tus/server/rufh/validation/RufhUploadExistsValidatorTest.java b/src/test/java/me/desair/tus/server/rufh/validation/RufhUploadExistsValidatorTest.java index df767bbc..311d03e7 100644 --- a/src/test/java/me/desair/tus/server/rufh/validation/RufhUploadExistsValidatorTest.java +++ b/src/test/java/me/desair/tus/server/rufh/validation/RufhUploadExistsValidatorTest.java @@ -32,13 +32,16 @@ public void setUp() { @Test public void testSupports() { assertTrue(validator.supports(HttpMethod.HEAD)); + assertTrue(validator.supports(HttpMethod.GET)); + assertTrue(validator.supports(HttpMethod.PATCH)); assertTrue(validator.supports(HttpMethod.DELETE)); assertFalse(validator.supports(HttpMethod.POST)); } /** - * Section 6.1 (Status Request) & Section 7 (Upload Cancellation): "If the upload resource does - * not exist, the server MUST reject the request with a 404 (Not Found) status code." + * Section 4.3 (Offset Retrieval), Section 4.4 (Upload Append) & Section 4.5 (Upload + * Cancellation): "If the upload resource does not exist, the server MUST reject the request with + * a 404 (Not Found) status code." */ @Test(expected = TusException.class) public void testValidateUploadDoesNotExist() throws Exception { @@ -48,6 +51,18 @@ public void testValidateUploadDoesNotExist() throws Exception { validator.validate(HttpMethod.HEAD, request, storageService, "owner"); } + /** + * Section 4.4 (Upload Append): "If the upload resource does not exist, the server MUST reject the + * request with a 404 (Not Found) status code." + */ + @Test(expected = TusException.class) + public void testValidatePatchUploadDoesNotExist() throws Exception { + request.setRequestURI("/files/non-existent-id"); + when(storageService.getUploadInfo("/files/non-existent-id", "owner")).thenReturn(null); + + validator.validate(HttpMethod.PATCH, request, storageService, "owner"); + } + @Test public void testValidateUploadExists() throws Exception { request.setRequestURI("/files/exists-id"); diff --git a/src/test/java/me/desair/tus/server/upload/cache/ThreadLocalCachedStorageAndLockingServiceTest.java b/src/test/java/me/desair/tus/server/upload/cache/ThreadLocalCachedStorageAndLockingServiceTest.java index 1e7de85b..49ce17b2 100644 --- a/src/test/java/me/desair/tus/server/upload/cache/ThreadLocalCachedStorageAndLockingServiceTest.java +++ b/src/test/java/me/desair/tus/server/upload/cache/ThreadLocalCachedStorageAndLockingServiceTest.java @@ -64,6 +64,11 @@ public void testGetUploadInfoAndCaching() throws IOException { verify(mockStorage, times(1)).getUploadInfo(id); } + @Test + public void testGetUploadInfoWithNullId() throws IOException { + org.junit.Assert.assertNull(service.getUploadInfo((UploadId) null)); + } + @Test public void testGetUploadInfoByUrlAndCaching() throws IOException { UploadId id = new UploadId(UUID.randomUUID().toString()); 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 281c6768..533253ca 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 @@ -113,6 +113,11 @@ public void getMaxUploadSize() throws Exception { assertThat(storageService.getMaxUploadSize(), is(372036854775807L)); } + @Test + public void testGetUploadInfoWithNullId() throws Exception { + org.junit.Assert.assertNull(storageService.getUploadInfo((UploadId) null)); + } + @Test public void getUploadUri() throws Exception { assertThat(storageService.getUploadUri(), is(UPLOAD_URL)); diff --git a/src/test/java/me/desair/tus/server/util/UtilsTest.java b/src/test/java/me/desair/tus/server/util/UtilsTest.java index a4bc37a9..bbd4c681 100644 --- a/src/test/java/me/desair/tus/server/util/UtilsTest.java +++ b/src/test/java/me/desair/tus/server/util/UtilsTest.java @@ -324,6 +324,11 @@ public void testGetUploadUri() { @Test public void testDetectProtocolVersion() { + HttpServletRequest unversionedRequest = mock(HttpServletRequest.class); + assertThat( + Utils.detectProtocolVersion(unversionedRequest, me.desair.tus.server.ProtocolVersion.AUTO), + is(me.desair.tus.server.ProtocolVersion.TUS_1_0_0)); + HttpServletRequest request = mock(HttpServletRequest.class); // AUTO mode with Tus-Resumable header -> TUS_1_0_0 @@ -333,10 +338,10 @@ public void testDetectProtocolVersion() { is(me.desair.tus.server.ProtocolVersion.TUS_1_0_0)); // AUTO mode with Upload-Complete header -> RUFH - when(request.getHeader(HttpHeader.TUS_RESUMABLE)).thenReturn(null); - when(request.getHeader(HttpHeader.UPLOAD_COMPLETE)).thenReturn("?0"); + HttpServletRequest rufhReq = mock(HttpServletRequest.class); + when(rufhReq.getHeader(HttpHeader.UPLOAD_COMPLETE)).thenReturn("?0"); assertThat( - Utils.detectProtocolVersion(request, me.desair.tus.server.ProtocolVersion.AUTO), + Utils.detectProtocolVersion(rufhReq, me.desair.tus.server.ProtocolVersion.AUTO), is(me.desair.tus.server.ProtocolVersion.RUFH)); // Explicit TUS_1_0_0 mode @@ -386,6 +391,127 @@ public void testGetUploadUriOnCreation() { is("/")); } + @Test + public void testIsExistingUploadResource() throws Exception { + assertThat(Utils.isExistingUploadResource(null, null, "owner"), is(false)); + + HttpServletRequest request = mock(HttpServletRequest.class); + me.desair.tus.server.upload.UploadStorageService storageService = + mock(me.desair.tus.server.upload.UploadStorageService.class); + + when(request.getRequestURI()).thenReturn("/files"); + when(storageService.getUploadUri()).thenReturn("/files"); + assertThat(Utils.isExistingUploadResource(request, storageService, "owner"), is(false)); + + when(request.getRequestURI()).thenReturn("/files/"); + assertThat(Utils.isExistingUploadResource(request, storageService, "owner"), is(false)); + + when(request.getRequestURI()).thenReturn("/files/123"); + when(storageService.getUploadInfo("/files/123", "owner")).thenReturn(null); + assertThat(Utils.isExistingUploadResource(request, storageService, "owner"), is(false)); + + me.desair.tus.server.upload.UploadInfo info = new me.desair.tus.server.upload.UploadInfo(); + when(storageService.getUploadInfo("/files/123", "owner")).thenReturn(info); + assertThat(Utils.isExistingUploadResource(request, storageService, "owner"), is(true)); + } + + @Test + public void testIsCreationEndpoint() throws Exception { + assertThat(Utils.isCreationEndpoint(null, null), is(false)); + + HttpServletRequest request = mock(HttpServletRequest.class); + me.desair.tus.server.upload.UploadStorageService storageService = + mock(me.desair.tus.server.upload.UploadStorageService.class); + + when(request.getRequestURI()).thenReturn("/files"); + when(storageService.getUploadUri()).thenReturn("/files"); + assertThat(Utils.isCreationEndpoint(request, storageService), is(true)); + + when(request.getRequestURI()).thenReturn("/files/"); + assertThat(Utils.isCreationEndpoint(request, storageService), is(true)); + + when(request.getRequestURI()).thenReturn("/files/123"); + assertThat(Utils.isCreationEndpoint(request, storageService), is(false)); + } + + @Test + public void testDetermineProtocolVersionBranches() { + HttpServletRequest request = mock(HttpServletRequest.class); + + // Content-Type: application/offset+octet-stream + when(request.getHeader(HttpHeader.CONTENT_TYPE)).thenReturn("application/offset+octet-stream"); + assertThat( + Utils.detectProtocolVersion(request, me.desair.tus.server.ProtocolVersion.AUTO), + is(me.desair.tus.server.ProtocolVersion.RUFH)); + + // Method HEAD without tus headers + when(request.getHeader(HttpHeader.CONTENT_TYPE)).thenReturn(null); + when(request.getMethod()).thenReturn("HEAD"); + assertThat( + Utils.detectProtocolVersion(request, me.desair.tus.server.ProtocolVersion.AUTO), + is(me.desair.tus.server.ProtocolVersion.RUFH)); + + // Method DELETE without tus headers + when(request.getMethod()).thenReturn("DELETE"); + assertThat( + Utils.detectProtocolVersion(request, me.desair.tus.server.ProtocolVersion.AUTO), + is(me.desair.tus.server.ProtocolVersion.RUFH)); + + // Method POST with Upload-Offset + when(request.getMethod()).thenReturn("POST"); + when(request.getHeader(HttpHeader.UPLOAD_OFFSET)).thenReturn("0"); + assertThat( + Utils.detectProtocolVersion(request, me.desair.tus.server.ProtocolVersion.AUTO), + is(me.desair.tus.server.ProtocolVersion.RUFH)); + } + + @Test + public void testIsCreationEndpointEdgeCases() { + me.desair.tus.server.upload.UploadStorageService storageService = + mock(me.desair.tus.server.upload.UploadStorageService.class); + HttpServletRequest request = mock(HttpServletRequest.class); + + assertThat(Utils.isCreationEndpoint(request, null), is(false)); + assertThat(Utils.isCreationEndpoint(null, storageService), is(false)); + + when(request.getRequestURI()).thenReturn(null); + when(storageService.getUploadUri()).thenReturn("/files"); + assertThat(Utils.isCreationEndpoint(request, storageService), is(false)); + + when(request.getRequestURI()).thenReturn("/files"); + when(storageService.getUploadUri()).thenReturn(null); + assertThat(Utils.isCreationEndpoint(request, storageService), is(false)); + } + + @Test + public void testIsExistingUploadResourceExpiredAndNull() throws Exception { + HttpServletRequest request = mock(HttpServletRequest.class); + me.desair.tus.server.upload.UploadStorageService storageService = + mock(me.desair.tus.server.upload.UploadStorageService.class); + + when(request.getRequestURI()).thenReturn("/files/123"); + when(storageService.getUploadUri()).thenReturn("/files"); + + me.desair.tus.server.upload.UploadInfo expired = new me.desair.tus.server.upload.UploadInfo(); + expired.setExpirationTimestamp(System.currentTimeMillis() - 1000L); + when(storageService.getUploadInfo("/files/123", "owner")).thenReturn(expired); + + assertThat(Utils.isExistingUploadResource(request, storageService, "owner"), is(false)); + assertThat(Utils.isExistingUploadResource(request, null, "owner"), is(false)); + } + + @Test + public void testIsExistingUploadResourceNullRequestUri() throws Exception { + HttpServletRequest request = mock(HttpServletRequest.class); + me.desair.tus.server.upload.UploadStorageService storageService = + mock(me.desair.tus.server.upload.UploadStorageService.class); + + when(request.getRequestURI()).thenReturn(null); + when(storageService.getUploadUri()).thenReturn("/files"); + + assertThat(Utils.isExistingUploadResource(request, storageService, "owner"), is(false)); + } + /** Simple serializable class for testing. */ public static class TestSerializable implements Serializable { private static final long serialVersionUID = 1L;