Merge pull request #437 from EdouardVanbelle/fix/nextcloud-propfind-on-chunk-upload
This commit is contained in:
@@ -139,6 +139,92 @@ impl NextcloudChunkedUploadService {
|
|||||||
.map(|p| p.exists())
|
.map(|p| p.exists())
|
||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Enumerate the chunks already stored in a session, plus the
|
||||||
|
/// session directory's own mtime. Used by the PROPFIND handler
|
||||||
|
/// to drive NextCloud's resume-upload flow — the Android client
|
||||||
|
/// (and several mobile clients) issue PROPFIND on the session
|
||||||
|
/// URL to discover which chunks are already uploaded, then only
|
||||||
|
/// PUT the missing ones.
|
||||||
|
///
|
||||||
|
/// Returns `None` when the session directory doesn't exist
|
||||||
|
/// (handler maps to 404). The `.file` and `.assembled` markers
|
||||||
|
/// are filtered out — they're internal bookkeeping, not real
|
||||||
|
/// chunks the client uploaded.
|
||||||
|
pub async fn list_chunks(&self, user: &str, upload_id: &str) -> Result<Option<SessionListing>> {
|
||||||
|
let session_dir = self.safe_session_dir(user, upload_id)?;
|
||||||
|
if !session_dir.exists() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let session_meta = fs::metadata(&session_dir)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::internal_error("ChunkedUpload", e.to_string()))?;
|
||||||
|
let session_mtime = session_meta
|
||||||
|
.modified()
|
||||||
|
.ok()
|
||||||
|
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||||
|
.map(|d| d.as_secs())
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
let mut chunks: Vec<ChunkInfo> = Vec::new();
|
||||||
|
let mut dir = fs::read_dir(&session_dir)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::internal_error("ChunkedUpload", e.to_string()))?;
|
||||||
|
while let Some(entry) = dir
|
||||||
|
.next_entry()
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::internal_error("ChunkedUpload", e.to_string()))?
|
||||||
|
{
|
||||||
|
let name = entry.file_name().to_string_lossy().to_string();
|
||||||
|
// Filter internal markers — `.file` is the NC-protocol
|
||||||
|
// assembly trigger target (it never reaches the disk
|
||||||
|
// because MOVE redirects it), `.assembled` is our own
|
||||||
|
// staging file from `assemble()`. Surfacing either to
|
||||||
|
// the client would confuse its chunk-count check.
|
||||||
|
if name == ".file" || name == ".assembled" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let meta = entry
|
||||||
|
.metadata()
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::internal_error("ChunkedUpload", e.to_string()))?;
|
||||||
|
let size = meta.len();
|
||||||
|
let mtime = meta
|
||||||
|
.modified()
|
||||||
|
.ok()
|
||||||
|
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||||
|
.map(|d| d.as_secs())
|
||||||
|
.unwrap_or(0);
|
||||||
|
chunks.push(ChunkInfo { name, size, mtime });
|
||||||
|
}
|
||||||
|
// Sort by chunk name so PROPFIND output is deterministic
|
||||||
|
// (clients don't strictly require this, but reproducible
|
||||||
|
// listings make debugging from logs much easier).
|
||||||
|
chunks.sort_by(|a, b| a.name.cmp(&b.name));
|
||||||
|
|
||||||
|
Ok(Some(SessionListing {
|
||||||
|
session_mtime,
|
||||||
|
chunks,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One chunk file inside an upload session.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ChunkInfo {
|
||||||
|
pub name: String,
|
||||||
|
pub size: u64,
|
||||||
|
pub mtime: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What `list_chunks` returns: the session's own mtime (for the
|
||||||
|
/// collection's `<d:getlastmodified>`) plus the list of stored
|
||||||
|
/// chunks.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SessionListing {
|
||||||
|
pub session_mtime: u64,
|
||||||
|
pub chunks: Vec<ChunkInfo>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -14,10 +14,11 @@ use crate::interfaces::middleware::auth::{AuthUser, CurrentUser};
|
|||||||
/// Dispatch Nextcloud chunked upload WebDAV requests.
|
/// Dispatch Nextcloud chunked upload WebDAV requests.
|
||||||
///
|
///
|
||||||
/// Routes:
|
/// Routes:
|
||||||
/// MKCOL /remote.php/dav/uploads/{user}/{upload_id} → create session
|
/// MKCOL /remote.php/dav/uploads/{user}/{upload_id} → create session
|
||||||
/// PUT /remote.php/dav/uploads/{user}/{upload_id}/{chunk} → store chunk
|
/// PUT /remote.php/dav/uploads/{user}/{upload_id}/{chunk} → store chunk
|
||||||
/// MOVE /remote.php/dav/uploads/{user}/{upload_id}/.file → assemble
|
/// MOVE /remote.php/dav/uploads/{user}/{upload_id}/.file → assemble
|
||||||
/// DELETE /remote.php/dav/uploads/{user}/{upload_id} → abort
|
/// DELETE /remote.php/dav/uploads/{user}/{upload_id} → abort
|
||||||
|
/// PROPFIND /remote.php/dav/uploads/{user}/{upload_id} → list chunks (for resume)
|
||||||
pub async fn handle_nc_uploads(
|
pub async fn handle_nc_uploads(
|
||||||
state: Arc<AppState>,
|
state: Arc<AppState>,
|
||||||
req: Request<Body>,
|
req: Request<Body>,
|
||||||
@@ -31,6 +32,7 @@ pub async fn handle_nc_uploads(
|
|||||||
"PUT" => handle_put_chunk(state, req, &user, &upload_id, &rest).await,
|
"PUT" => handle_put_chunk(state, req, &user, &upload_id, &rest).await,
|
||||||
"MOVE" => handle_assemble(state, req, &user, &upload_id).await,
|
"MOVE" => handle_assemble(state, req, &user, &upload_id).await,
|
||||||
"DELETE" => handle_abort(state, &user, &upload_id).await,
|
"DELETE" => handle_abort(state, &user, &upload_id).await,
|
||||||
|
"PROPFIND" => handle_propfind_session(state, &user, &upload_id).await,
|
||||||
_ => Ok(Response::builder()
|
_ => Ok(Response::builder()
|
||||||
.status(StatusCode::METHOD_NOT_ALLOWED)
|
.status(StatusCode::METHOD_NOT_ALLOWED)
|
||||||
.body(Body::empty())
|
.body(Body::empty())
|
||||||
@@ -38,6 +40,107 @@ pub async fn handle_nc_uploads(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// PROPFIND on an upload session — used by the NextCloud Android
|
||||||
|
/// client (and several mobile clients) to enumerate which chunks
|
||||||
|
/// are already uploaded before resuming an interrupted transfer.
|
||||||
|
/// Without this handler the client gets `405 METHOD_NOT_ALLOWED`
|
||||||
|
/// and falls back to either failing the upload or starting from
|
||||||
|
/// scratch — neither is acceptable on cellular / flaky links where
|
||||||
|
/// resume is the whole point of chunked upload.
|
||||||
|
///
|
||||||
|
/// Response shape: 207 Multi-Status with one `<d:response>` for the
|
||||||
|
/// session collection itself and one per chunk file. Properties
|
||||||
|
/// returned are the minimum the NC client reads: `resourcetype`,
|
||||||
|
/// `getcontentlength` (chunks only), and `getlastmodified` (so
|
||||||
|
/// clients can detect stale partial uploads). Depth is ignored —
|
||||||
|
/// we always return one level (the session + its direct chunks),
|
||||||
|
/// which matches NC server behaviour.
|
||||||
|
async fn handle_propfind_session(
|
||||||
|
state: Arc<AppState>,
|
||||||
|
user: &CurrentUser,
|
||||||
|
upload_id: &str,
|
||||||
|
) -> Result<Response<Body>, AppError> {
|
||||||
|
let nc = state
|
||||||
|
.nextcloud
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| AppError::internal_error("Nextcloud services unavailable"))?;
|
||||||
|
|
||||||
|
let listing = nc
|
||||||
|
.chunked_uploads
|
||||||
|
.list_chunks(&user.username, upload_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::internal_error(format!("Failed to list chunks: {}", e)))?
|
||||||
|
.ok_or_else(|| AppError::not_found("Upload session not found"))?;
|
||||||
|
|
||||||
|
let session_href = format!("/remote.php/dav/uploads/{}/{}/", user.username, upload_id);
|
||||||
|
let session_last_modified =
|
||||||
|
chrono::DateTime::<chrono::Utc>::from_timestamp(listing.session_mtime as i64, 0)
|
||||||
|
.unwrap_or_else(chrono::Utc::now)
|
||||||
|
.to_rfc2822();
|
||||||
|
|
||||||
|
let mut body = String::new();
|
||||||
|
body.push_str(r#"<?xml version="1.0" encoding="utf-8"?>"#);
|
||||||
|
body.push_str(r#"<d:multistatus xmlns:d="DAV:">"#);
|
||||||
|
|
||||||
|
// Session collection itself.
|
||||||
|
body.push_str("<d:response>");
|
||||||
|
body.push_str(&format!("<d:href>{}</d:href>", xml_escape(&session_href)));
|
||||||
|
body.push_str("<d:propstat><d:prop>");
|
||||||
|
body.push_str("<d:resourcetype><d:collection/></d:resourcetype>");
|
||||||
|
body.push_str(&format!(
|
||||||
|
"<d:getlastmodified>{}</d:getlastmodified>",
|
||||||
|
xml_escape(&session_last_modified)
|
||||||
|
));
|
||||||
|
body.push_str("</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat>");
|
||||||
|
body.push_str("</d:response>");
|
||||||
|
|
||||||
|
// One entry per chunk file.
|
||||||
|
for chunk in &listing.chunks {
|
||||||
|
let chunk_href = format!(
|
||||||
|
"/remote.php/dav/uploads/{}/{}/{}",
|
||||||
|
user.username, upload_id, chunk.name
|
||||||
|
);
|
||||||
|
let chunk_modified = chrono::DateTime::<chrono::Utc>::from_timestamp(chunk.mtime as i64, 0)
|
||||||
|
.unwrap_or_else(chrono::Utc::now)
|
||||||
|
.to_rfc2822();
|
||||||
|
|
||||||
|
body.push_str("<d:response>");
|
||||||
|
body.push_str(&format!("<d:href>{}</d:href>", xml_escape(&chunk_href)));
|
||||||
|
body.push_str("<d:propstat><d:prop>");
|
||||||
|
body.push_str("<d:resourcetype/>");
|
||||||
|
body.push_str(&format!(
|
||||||
|
"<d:getcontentlength>{}</d:getcontentlength>",
|
||||||
|
chunk.size
|
||||||
|
));
|
||||||
|
body.push_str(&format!(
|
||||||
|
"<d:getlastmodified>{}</d:getlastmodified>",
|
||||||
|
xml_escape(&chunk_modified)
|
||||||
|
));
|
||||||
|
body.push_str("</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat>");
|
||||||
|
body.push_str("</d:response>");
|
||||||
|
}
|
||||||
|
|
||||||
|
body.push_str("</d:multistatus>");
|
||||||
|
|
||||||
|
Ok(Response::builder()
|
||||||
|
.status(StatusCode::MULTI_STATUS)
|
||||||
|
.header(header::CONTENT_TYPE, "application/xml; charset=utf-8")
|
||||||
|
.body(Body::from(body))
|
||||||
|
.unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Minimal XML escape — every value we inject above is either a
|
||||||
|
/// well-formed RFC 2822 date, a number, or a path segment we
|
||||||
|
/// control, but defense-in-depth keeps the response well-formed
|
||||||
|
/// even if a chunk name ever contained an unexpected character.
|
||||||
|
fn xml_escape(s: &str) -> String {
|
||||||
|
s.replace('&', "&")
|
||||||
|
.replace('<', "<")
|
||||||
|
.replace('>', ">")
|
||||||
|
.replace('"', """)
|
||||||
|
.replace('\'', "'")
|
||||||
|
}
|
||||||
|
|
||||||
/// MKCOL — create upload session directory.
|
/// MKCOL — create upload session directory.
|
||||||
async fn handle_mkcol(
|
async fn handle_mkcol(
|
||||||
state: Arc<AppState>,
|
state: Arc<AppState>,
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ OXICLOUD_ENABLE_MUSIC=true
|
|||||||
OXICLOUD_EXPOSE_SYSTEM_USERS=true
|
OXICLOUD_EXPOSE_SYSTEM_USERS=true
|
||||||
OXICLOUD_WOPI_ENABLED=false
|
OXICLOUD_WOPI_ENABLED=false
|
||||||
OXICLOUD_OIDC_ENABLED=false
|
OXICLOUD_OIDC_ENABLED=false
|
||||||
|
|
||||||
|
OXICLOUD_NEXTCLOUD_ENABLED=true
|
||||||
|
|
||||||
RUST_LOG="warn,audit=info"
|
RUST_LOG="warn,audit=info"
|
||||||
#RUST_LOG=debug
|
#RUST_LOG=debug
|
||||||
#RUST_LOG=info
|
#RUST_LOG=info
|
||||||
|
|||||||
+212
@@ -0,0 +1,212 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# =============================================================
|
||||||
|
# OxiCloud – NextCloud chunked-upload PROPFIND (resume support)
|
||||||
|
# =============================================================
|
||||||
|
# Verifies the PROPFIND handler on /remote.php/dav/uploads/{user}/{upload_id}.
|
||||||
|
#
|
||||||
|
# Why this matters: the NextCloud Android client (and several other
|
||||||
|
# mobile NC clients) issue PROPFIND on the upload-session URL before
|
||||||
|
# resuming an interrupted chunked upload. Without it the client gets
|
||||||
|
# 405 METHOD_NOT_ALLOWED and either restarts the whole upload from
|
||||||
|
# scratch or fails outright. This was a real bug reported against the
|
||||||
|
# OxiCloud NC gateway by an admin running NC Android 3.31.1.
|
||||||
|
#
|
||||||
|
# Sequence:
|
||||||
|
# 1. Login → JWT, then mint an app password (NC surface requires
|
||||||
|
# Basic Auth with an app password, not the user's login password).
|
||||||
|
# 2. MKCOL the session → 201.
|
||||||
|
# 3. PROPFIND empty session → 207 Multi-Status with exactly ONE
|
||||||
|
# <d:response> entry (the session collection itself).
|
||||||
|
# 4. PUT two chunks (00000001, 00000002).
|
||||||
|
# 5. PROPFIND again → 207 with THREE entries (collection + 2 chunks).
|
||||||
|
# Asserts the chunk byte counts come back correctly so the
|
||||||
|
# Android client's resume logic can compare against its expected
|
||||||
|
# chunk sizes.
|
||||||
|
# 6. Security checks:
|
||||||
|
# a. PROPFIND with mismatched URL user → 403.
|
||||||
|
# b. PROPFIND on a session that doesn't exist → 404.
|
||||||
|
# c. PROPFIND with no auth → 401.
|
||||||
|
# 7. DELETE the session → 204.
|
||||||
|
#
|
||||||
|
# Prerequisites:
|
||||||
|
# - Server running at base_url
|
||||||
|
# - OXICLOUD_ENABLE_AUTH=true (NC surface is auth-only)
|
||||||
|
# - jq, curl, xmllint in PATH
|
||||||
|
#
|
||||||
|
# Run (from repo root):
|
||||||
|
# bash tests/webdav/test_nextcloud_chunked_upload_propfind.sh
|
||||||
|
# =============================================================
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
|
source test.env
|
||||||
|
source common.sh
|
||||||
|
|
||||||
|
# ── helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
PASS=0
|
||||||
|
FAIL=0
|
||||||
|
|
||||||
|
pass() { PASS=$(( PASS + 1 )); echo " PASS: $*"; }
|
||||||
|
fail() { FAIL=$(( FAIL + 1 )); echo " FAIL: $*" >&2; exit 1; }
|
||||||
|
|
||||||
|
# All NC-surface curls go through this helper so the Basic-Auth
|
||||||
|
# header (app password, not the user's login password) is applied
|
||||||
|
# uniformly. The NC handler rejects login-password Basic Auth — only
|
||||||
|
# app passwords pass the verify_basic_auth check.
|
||||||
|
nc_curl() {
|
||||||
|
curl -s -u "$username:$APP_PASS" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Mint an app password using the JWT we just got. The NC handlers
|
||||||
|
# accept only app-password Basic Auth (rfc 7617), never the login
|
||||||
|
# password, so this step is mandatory.
|
||||||
|
#
|
||||||
|
# Request DTO is `CreateAppPasswordRequestDto`: `label` is required,
|
||||||
|
# `scopes` and `expires_in_days` are optional. Sending the wrong
|
||||||
|
# field name makes axum's Json extractor return a plain-text error
|
||||||
|
# body that subsequent jq parsing chokes on with "Invalid numeric
|
||||||
|
# literal" — a confusing error mode worth pinning the field name
|
||||||
|
# against.
|
||||||
|
mint_app_password() {
|
||||||
|
local response
|
||||||
|
response=$(curl -s -X POST \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"label":"nc-chunked-upload-propfind-test"}' \
|
||||||
|
"$base_url/api/auth/app-passwords")
|
||||||
|
APP_PASS=$(jq -r '.password // empty' <<< "$response" 2>/dev/null || echo "")
|
||||||
|
[[ -n "$APP_PASS" ]] || fail "Could not mint app password (response: $response)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Count <d:response> children in a multistatus body. We don't use a
|
||||||
|
# full XML parser because the response shape is fixed by our handler
|
||||||
|
# and a plain grep is robust enough for the assertions we need.
|
||||||
|
count_responses() {
|
||||||
|
grep -o '<d:response>' <<< "$1" | wc -l | tr -d ' '
|
||||||
|
}
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "=== NextCloud chunked-upload PROPFIND (resume support) ==="
|
||||||
|
echo
|
||||||
|
|
||||||
|
# ── authenticate + mint app password ─────────────────────────────────────────
|
||||||
|
|
||||||
|
oxicloud_login
|
||||||
|
mint_app_password
|
||||||
|
|
||||||
|
UPLOAD_ID="test-propfind-$(date +%s)-$$"
|
||||||
|
NC_BASE="$base_url/remote.php/dav/uploads/$username/$UPLOAD_ID"
|
||||||
|
|
||||||
|
# Idempotent pre-test cleanup — drop any stale session from a previous run.
|
||||||
|
nc_curl -o /dev/null -X DELETE "$NC_BASE" > /dev/null 2>&1 || true
|
||||||
|
|
||||||
|
# ── Step 1: MKCOL — create session ───────────────────────────────────────────
|
||||||
|
|
||||||
|
echo " step 1: MKCOL $NC_BASE"
|
||||||
|
STATUS=$(nc_curl -o /dev/null -w "%{http_code}" -X MKCOL "$NC_BASE")
|
||||||
|
[[ "$STATUS" == "201" ]] || fail "MKCOL expected 201, got $STATUS"
|
||||||
|
pass "Session created (MKCOL → 201)"
|
||||||
|
|
||||||
|
# ── Step 2: PROPFIND on empty session — only the collection ──────────────────
|
||||||
|
|
||||||
|
echo " step 2: PROPFIND on empty session"
|
||||||
|
BODY=$(nc_curl -X PROPFIND -H "Depth: 1" "$NC_BASE")
|
||||||
|
[[ "$BODY" == *"<d:multistatus"* ]] \
|
||||||
|
|| fail "PROPFIND empty session: not a multistatus body (got: $BODY)"
|
||||||
|
N=$(count_responses "$BODY")
|
||||||
|
[[ "$N" == "1" ]] \
|
||||||
|
|| fail "PROPFIND empty session: expected 1 <d:response>, got $N (body: $BODY)"
|
||||||
|
[[ "$BODY" == *"<d:collection/>"* ]] \
|
||||||
|
|| fail "PROPFIND empty session: collection marker missing"
|
||||||
|
pass "Empty-session PROPFIND returns 1 response (collection only)"
|
||||||
|
|
||||||
|
# ── Step 3: PUT two chunks ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
echo " step 3: PUT chunks 00000001 and 00000002"
|
||||||
|
CHUNK1_SIZE=$(printf "first chunk bytes" | wc -c | tr -d ' ')
|
||||||
|
CHUNK2_SIZE=$(printf "second chunk has different size" | wc -c | tr -d ' ')
|
||||||
|
|
||||||
|
STATUS=$(printf "first chunk bytes" | nc_curl -o /dev/null -w "%{http_code}" \
|
||||||
|
-X PUT -H "Content-Type: application/octet-stream" --data-binary @- \
|
||||||
|
"$NC_BASE/00000001")
|
||||||
|
[[ "$STATUS" == "201" ]] || fail "PUT chunk 00000001 expected 201, got $STATUS"
|
||||||
|
|
||||||
|
STATUS=$(printf "second chunk has different size" | nc_curl -o /dev/null -w "%{http_code}" \
|
||||||
|
-X PUT -H "Content-Type: application/octet-stream" --data-binary @- \
|
||||||
|
"$NC_BASE/00000002")
|
||||||
|
[[ "$STATUS" == "201" ]] || fail "PUT chunk 00000002 expected 201, got $STATUS"
|
||||||
|
pass "Both chunks uploaded ($CHUNK1_SIZE B + $CHUNK2_SIZE B)"
|
||||||
|
|
||||||
|
# ── Step 4: PROPFIND populated session ───────────────────────────────────────
|
||||||
|
|
||||||
|
echo " step 4: PROPFIND populated session"
|
||||||
|
BODY=$(nc_curl -X PROPFIND -H "Depth: 1" "$NC_BASE")
|
||||||
|
N=$(count_responses "$BODY")
|
||||||
|
[[ "$N" == "3" ]] \
|
||||||
|
|| fail "PROPFIND populated session: expected 3 <d:response>, got $N (body: $BODY)"
|
||||||
|
pass "Populated PROPFIND returns 3 responses (collection + 2 chunks)"
|
||||||
|
|
||||||
|
# Chunk hrefs appear in the body.
|
||||||
|
[[ "$BODY" == *"/$UPLOAD_ID/00000001"* ]] \
|
||||||
|
|| fail "PROPFIND populated session: chunk 00000001 href missing"
|
||||||
|
[[ "$BODY" == *"/$UPLOAD_ID/00000002"* ]] \
|
||||||
|
|| fail "PROPFIND populated session: chunk 00000002 href missing"
|
||||||
|
pass "Both chunk hrefs present in multistatus body"
|
||||||
|
|
||||||
|
# Chunk content-lengths match what we uploaded — this is the field
|
||||||
|
# the Android client reads to decide whether a stored chunk is
|
||||||
|
# "complete" or "partial".
|
||||||
|
[[ "$BODY" == *"<d:getcontentlength>$CHUNK1_SIZE</d:getcontentlength>"* ]] \
|
||||||
|
|| fail "PROPFIND: chunk 00000001 size $CHUNK1_SIZE missing in body"
|
||||||
|
[[ "$BODY" == *"<d:getcontentlength>$CHUNK2_SIZE</d:getcontentlength>"* ]] \
|
||||||
|
|| fail "PROPFIND: chunk 00000002 size $CHUNK2_SIZE missing in body"
|
||||||
|
pass "Chunk getcontentlength values match upload byte counts"
|
||||||
|
|
||||||
|
# ── Step 5a: Security — URL user doesn't match auth user → 403 ────────────────
|
||||||
|
|
||||||
|
echo " step 5a: PROPFIND with mismatched URL user → 403"
|
||||||
|
STATUS=$(nc_curl -o /dev/null -w "%{http_code}" -X PROPFIND \
|
||||||
|
"$base_url/remote.php/dav/uploads/someoneelse/$UPLOAD_ID")
|
||||||
|
[[ "$STATUS" == "403" ]] \
|
||||||
|
|| fail "Mismatched-user PROPFIND expected 403, got $STATUS"
|
||||||
|
pass "Mismatched URL user rejected with 403"
|
||||||
|
|
||||||
|
# ── Step 5b: Security — nonexistent session → 404 ────────────────────────────
|
||||||
|
|
||||||
|
echo " step 5b: PROPFIND on nonexistent session → 404"
|
||||||
|
STATUS=$(nc_curl -o /dev/null -w "%{http_code}" -X PROPFIND \
|
||||||
|
"$base_url/remote.php/dav/uploads/$username/does-not-exist-$$-$(date +%s)")
|
||||||
|
[[ "$STATUS" == "404" ]] \
|
||||||
|
|| fail "Nonexistent-session PROPFIND expected 404, got $STATUS"
|
||||||
|
pass "Nonexistent session returns 404"
|
||||||
|
|
||||||
|
# ── Step 5c: Security — no auth → 401 ────────────────────────────────────────
|
||||||
|
|
||||||
|
echo " step 5c: PROPFIND with no auth → 401"
|
||||||
|
STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X PROPFIND "$NC_BASE")
|
||||||
|
[[ "$STATUS" == "401" ]] \
|
||||||
|
|| fail "Unauthenticated PROPFIND expected 401, got $STATUS"
|
||||||
|
pass "Unauthenticated request rejected with 401"
|
||||||
|
|
||||||
|
# ── Cleanup ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
echo " cleanup..."
|
||||||
|
STATUS=$(nc_curl -o /dev/null -w "%{http_code}" -X DELETE "$NC_BASE")
|
||||||
|
[[ "$STATUS" == "204" ]] || fail "DELETE session expected 204, got $STATUS"
|
||||||
|
pass "Session deleted"
|
||||||
|
|
||||||
|
# Confirm cleanup: PROPFIND now returns 404.
|
||||||
|
STATUS=$(nc_curl -o /dev/null -w "%{http_code}" -X PROPFIND "$NC_BASE")
|
||||||
|
[[ "$STATUS" == "404" ]] \
|
||||||
|
|| fail "Post-delete PROPFIND expected 404, got $STATUS"
|
||||||
|
pass "Post-delete PROPFIND confirms session gone"
|
||||||
|
|
||||||
|
# ── summary ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Results: $PASS passed, $FAIL failed."
|
||||||
|
[[ "$FAIL" -eq 0 ]] && echo "All tests passed." || exit 1
|
||||||
Reference in New Issue
Block a user