Files
Oxicloud/tests/webdav/test_nextcloud_chunked_upload_propfind.sh
T

213 lines
9.8 KiB
Bash
Raw Normal View History

#!/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