3fc0a272ca
PROPFIND on upload chunk is necessary for application to resume the upload
it is used at least by Nextcloud Android app
issue has been raised via #415
unable to test, so I am using a playbook in test/webdav to simulate a
PROPIND on remote.php/dav/uploads/{user}/{session}
213 lines
9.8 KiB
Bash
Executable File
213 lines
9.8 KiB
Bash
Executable File
#!/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
|