Files
Oxicloud/tests/webdav/test_nc_propfind_baseline.sh
T
Edouard Vanbelle 595273277b test(e2e): webdav + nextcloud full e2e test coverage
add a full coverage of Webdav and Nextcloud
    purpose: prepare move to Drives and ensure no regression at all

    test scenarios are in docs/plan/BASELINE_TESTS_NC_WEBDAV.md

    current existing bugs identified via these tests:

      ┌──────────┬─────────┬────────────────────────────────────────────────────────────────────────────────────────────────────┐
      │   Bug    │ Surface │                                            Pin location                                            │
      ├──────────┼─────────┼────────────────────────────────────────────────────────────────────────────────────────────────────┤
      │ G4/G5/K5 │ NC      │ AlreadyExists → 500 instead of 412 (handle_move + trashbin restore)                                │
      ├──────────┼─────────┼────────────────────────────────────────────────────────────────────────────────────────────────────┤
      │ G9       │ NC      │ Folder DELETE not row-recursive — orphan descendants stay live                                     │
      ├──────────┼─────────┼────────────────────────────────────────────────────────────────────────────────────────────────────┤
      │ M5/M7    │ Native  │ resolve_path_for_user mismatch — PUT writes, GET reads via lenient lookup, MOVE/DELETE can't find  │
      │          │         │ via strict                                                                                         │
      ├──────────┼─────────┼────────────────────────────────────────────────────────────────────────────────────────────────────┤
      │ M8       │ Native  │ COPY discards destination filename — collides with source                                          │
      ├──────────┼─────────┼────────────────────────────────────────────────────────────────────────────────────────────────────┤
      │ N2       │ Native  │ LOCK creates the token, mutators don't check it — class-2 advertisement is aspirational            │
      └──────────┴─────────┴────────────────────────────────────────────────────────────────────────────────────────────────────┘
2026-06-13 19:01:40 +02:00

317 lines
17 KiB
Bash
Executable File

#!/usr/bin/env bash
# =============================================================
# OxiCloud — Baseline: NC WebDAV PROPFIND + OPTIONS
# =============================================================
# Group D from BASELINE_TESTS_NC_WEBDAV.md (11 scenarios).
#
# This is the read surface NC client touches first on every
# sync cycle. The headline guard is D8 / D9 / D10:
# trailing-slash semantics on collection vs file hrefs in
# multistatus responses — past regression where collection
# hrefs were emitted without `/` aborted NC desktop parsing
# with `Invalid href "<…>" expected starting with
# "<requested-url>"`.
#
# Sequence:
# D1 OPTIONS on the user's root collection
# D2 PROPFIND Depth: 0 on home
# D3 PROPFIND Depth: 1 on empty home (just created admin,
# no fixtures yet — fixtures arrive at the D4 step)
# D4 Upload 2 files + create 1 subfolder, PROPFIND Depth: 1
# D5 PROPFIND non-existent path → 404
# D6 PROPFIND on a file (not a collection)
# D7 PROPFIND Depth: infinity on a 3-level tree
# D8 PROPFIND on a subdirectory at Depth: 0 (trailing slash
# guard on its own href)
# D9 PROPFIND subdirectory Depth: 1 with mixed content
# (trailing slash guard on every child)
# D10 PROPFIND home Depth: 1 with mixed content (trailing
# slash guard on home's own entry + every child)
# D11 PROPFIND with malformed XML body → 400
# =============================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
source test.env
source common.sh
source lib/dav_helpers.sh
echo
echo "=== NC WebDAV PROPFIND + OPTIONS (Group D baseline) ==="
echo
oxicloud_login
mint_app_password
resolve_home_folder_id
# Defensive: this test's D4/D10 assertions count entries at the home
# root, so a leftover from an earlier script (e.g. the
# move_copy_delete_trash failures that 500-leak fixture files
# pinned as KNOWN BUG) would poison the count. Wipe to clean state.
wipe_home_folder
NC_FILES_BASE="$base_url/remote.php/dav/files/$username"
# ── Fixture setup (via REST so we don't depend on WebDAV-write paths) ────────
PROPFIND_FIXTURE_DIR=$(mktemp -d)
trap 'rm -rf "$PROPFIND_FIXTURE_DIR"' EXIT
echo "alpha contents" > "$PROPFIND_FIXTURE_DIR/alpha.txt"
echo "beta contents" > "$PROPFIND_FIXTURE_DIR/beta.txt"
echo "gamma contents" > "$PROPFIND_FIXTURE_DIR/gamma.txt"
# Subdir "sub-d" for D8 / D9, with mixed children:
# sub-d/{file1.txt, file2.txt, deepest1/, deepest2/}
# Plus a nested file under deepest1/ for D7 (Depth: infinity).
api_create_folder "sub-d" "$HOME_FOLDER_ID"
SUB_D_FOLDER_ID="$LAST_FOLDER_ID"
api_create_folder "deepest1" "$SUB_D_FOLDER_ID"
DEEPEST1_FOLDER_ID="$LAST_FOLDER_ID"
api_create_folder "deepest2" "$SUB_D_FOLDER_ID"
# DEEPEST2_FOLDER_ID not needed downstream — only its href
api_upload_file "$PROPFIND_FIXTURE_DIR/alpha.txt" "$SUB_D_FOLDER_ID"
api_upload_file "$PROPFIND_FIXTURE_DIR/beta.txt" "$SUB_D_FOLDER_ID"
# Deep file for D7 Depth: infinity.
api_upload_file "$PROPFIND_FIXTURE_DIR/gamma.txt" "$DEEPEST1_FOLDER_ID"
DEEP_FILE_ID="$LAST_FILE_ID"
# Mixed-content top-level entry for D10:
# home / { alpha-home.txt, sub-d/, sub-d-extra/ }
echo "home alpha" > "$PROPFIND_FIXTURE_DIR/alpha-home.txt"
api_upload_file "$PROPFIND_FIXTURE_DIR/alpha-home.txt" "$HOME_FOLDER_ID"
HOME_ALPHA_FILE_ID="$LAST_FILE_ID"
api_create_folder "sub-d-extra" "$HOME_FOLDER_ID"
SUB_EXTRA_FOLDER_ID="$LAST_FOLDER_ID"
echo
echo "Fixtures ready: 1 home-level file, 2 home-level folders (sub-d, sub-d-extra),"
echo "sub-d holds 2 files + 2 sub-subfolders, deepest1 holds 1 file."
echo
# ─────────────────────────────────────────────────────────────
# D1 — OPTIONS
# ─────────────────────────────────────────────────────────────
echo " D1: OPTIONS on root collection"
HEADERS=$(nc_curl -i -X OPTIONS "$NC_FILES_BASE/" | tr -d '\r')
STATUS=$(awk 'NR==1{print $2}' <<< "$HEADERS")
[[ "$STATUS" == "200" ]] \
|| fail "D1: OPTIONS expected 200, got $STATUS"
grep -qi '^dav:.*1.*3' <<< "$HEADERS" \
|| fail "D1: OPTIONS missing 'DAV: 1, 3' header"
grep -qi '^allow:.*PROPFIND' <<< "$HEADERS" \
|| fail "D1: Allow header missing PROPFIND"
grep -qi '^allow:.*PUT' <<< "$HEADERS" \
|| fail "D1: Allow header missing PUT"
grep -qi '^allow:.*REPORT' <<< "$HEADERS" \
|| fail "D1: Allow header missing REPORT"
pass "D1: OPTIONS advertises DAV 1, 3 + Allow includes PROPFIND/PUT/REPORT"
# ─────────────────────────────────────────────────────────────
# D2 — PROPFIND Depth: 0 home
# ─────────────────────────────────────────────────────────────
echo " D2: PROPFIND Depth: 0 on home root"
BODY=$(nc_curl -X PROPFIND -H "Depth: 0" "$NC_FILES_BASE/")
N=$(count_responses "$BODY")
[[ "$N" == "1" ]] \
|| fail "D2: Depth:0 expected 1 response, got $N"
HOME_HREF=$(extract_href_for "$BODY" "/dav/files/$username/")
[[ -n "$HOME_HREF" ]] \
|| fail "D2: home href not found in body"
[[ "$HOME_HREF" == */ ]] \
|| fail "D2: home href does NOT end with '/' — got '$HOME_HREF'"
grep -q '<d:collection/>' <<< "$BODY" \
|| fail "D2: home response missing <d:collection/>"
grep -q '<oc:fileid>' <<< "$BODY" \
|| fail "D2: home response missing <oc:fileid>"
pass "D2: home Depth:0 — 1 response, href ends '/', collection + fileid present"
# ─────────────────────────────────────────────────────────────
# D4 — PROPFIND Depth: 1 with mixed content
#
# We test D4 BEFORE D3 because we already set up fixtures.
# D3 (empty home) requires no fixtures, which is the natural
# state of a freshly-wiped storage but is broken by anything
# we did above. We re-create the empty-home invariant by
# moving the fixtures out of the way at D3-time.
# ─────────────────────────────────────────────────────────────
echo " D4: PROPFIND Depth: 1 on home (1 file + 2 folders ⇒ 4 responses)"
BODY=$(nc_curl -X PROPFIND -H "Depth: 1" "$NC_FILES_BASE/")
N=$(count_responses "$BODY")
[[ "$N" == "4" ]] \
|| fail "D4: Depth:1 expected 4 responses (collection + 1 file + 2 folders), got $N"
grep -q '<d:getcontentlength>' <<< "$BODY" \
|| fail "D4: at least one response should have <d:getcontentlength>"
assert_collection_hrefs_have_trailing_slash "$BODY" "D4"
pass "D4: 4 responses, trailing-slash semantics correct, content-length present"
# ─────────────────────────────────────────────────────────────
# D10 — PROPFIND home Depth: 1 mixed content
# OWN entry + every child checked for trailing-slash.
# The body from D4 already meets D10's setup — re-assert
# on it with explicit OWN-entry focus.
# ─────────────────────────────────────────────────────────────
echo " D10: PROPFIND home Depth: 1 — explicit OWN-entry trailing slash check"
# OWN entry's href is the home root, must end '/'
HOME_OWN_HREF=$(extract_href_for "$BODY" "/dav/files/$username/")
[[ -n "$HOME_OWN_HREF" ]] && [[ "$HOME_OWN_HREF" == */ ]] \
|| fail "D10: OWN-entry href absent or missing trailing slash: '$HOME_OWN_HREF'"
# Sub-d subfolder href (collection) must end '/'
SUB_D_HREF=$(extract_href_for "$BODY" "/dav/files/$username/sub-d")
[[ -n "$SUB_D_HREF" ]] && [[ "$SUB_D_HREF" == */ ]] \
|| fail "D10: sub-d folder href missing trailing slash: '$SUB_D_HREF'"
# File href must NOT end '/'
HOME_ALPHA_HREF=$(extract_href_for "$BODY" "/dav/files/$username/alpha-home.txt")
[[ -n "$HOME_ALPHA_HREF" ]] && [[ "$HOME_ALPHA_HREF" != */ ]] \
|| fail "D10: alpha-home.txt href must NOT end '/': got '$HOME_ALPHA_HREF'"
pass "D10: OWN entry + folder + file all have correct trailing-slash semantics"
# ─────────────────────────────────────────────────────────────
# D8 — PROPFIND on a subdirectory at Depth: 0
# Its OWN href must end '/'.
# ─────────────────────────────────────────────────────────────
echo " D8: PROPFIND Depth: 0 on subdirectory /sub-d/"
BODY=$(nc_curl -X PROPFIND -H "Depth: 0" "$NC_FILES_BASE/sub-d/")
N=$(count_responses "$BODY")
[[ "$N" == "1" ]] \
|| fail "D8: Depth:0 expected 1 response, got $N"
SUB_D_OWN_HREF=$(extract_href_for "$BODY" "/dav/files/$username/sub-d")
[[ -n "$SUB_D_OWN_HREF" ]] && [[ "$SUB_D_OWN_HREF" == */ ]] \
|| fail "D8: subdirectory OWN href missing trailing slash: '$SUB_D_OWN_HREF'"
grep -q '<d:collection/>' <<< "$BODY" \
|| fail "D8: sub-d response missing <d:collection/>"
pass "D8: subdir Depth:0 — OWN href ends '/' and resourcetype is collection"
# ─────────────────────────────────────────────────────────────
# D9 — PROPFIND on subdirectory Depth: 1 with mixed content
# sub-d holds 2 files + 2 sub-subfolders ⇒ 5 responses,
# every collection href ends '/', every file href doesn't.
# ─────────────────────────────────────────────────────────────
echo " D9: PROPFIND Depth: 1 on /sub-d/ (mixed children ⇒ 5 responses)"
BODY=$(nc_curl -X PROPFIND -H "Depth: 1" "$NC_FILES_BASE/sub-d/")
N=$(count_responses "$BODY")
[[ "$N" == "5" ]] \
|| fail "D9: Depth:1 on sub-d expected 5 responses, got $N"
assert_collection_hrefs_have_trailing_slash "$BODY" "D9"
# Spot-check the two specific children: deepest1/ is a collection;
# alpha.txt is a file.
DEEPEST1_HREF=$(extract_href_for "$BODY" "/dav/files/$username/sub-d/deepest1")
[[ -n "$DEEPEST1_HREF" ]] && [[ "$DEEPEST1_HREF" == */ ]] \
|| fail "D9: deepest1 folder href missing trailing slash: '$DEEPEST1_HREF'"
SUB_D_ALPHA_HREF=$(extract_href_for "$BODY" "/dav/files/$username/sub-d/alpha.txt")
[[ -n "$SUB_D_ALPHA_HREF" ]] && [[ "$SUB_D_ALPHA_HREF" != */ ]] \
|| fail "D9: alpha.txt href must NOT end '/': got '$SUB_D_ALPHA_HREF'"
pass "D9: 5 responses, trailing-slash semantics correct on every child"
# ─────────────────────────────────────────────────────────────
# D6 — PROPFIND on a file (not collection) at Depth: 0
# href must NOT end '/'.
# ─────────────────────────────────────────────────────────────
echo " D6: PROPFIND Depth: 0 on a file (not a collection)"
BODY=$(nc_curl -X PROPFIND -H "Depth: 0" "$NC_FILES_BASE/alpha-home.txt")
N=$(count_responses "$BODY")
[[ "$N" == "1" ]] \
|| fail "D6: Depth:0 on file expected 1 response, got $N"
FILE_HREF=$(extract_href_for "$BODY" "/dav/files/$username/alpha-home.txt")
[[ -n "$FILE_HREF" ]] && [[ "$FILE_HREF" != */ ]] \
|| fail "D6: file href must NOT end '/': got '$FILE_HREF'"
grep -q '<d:getcontentlength>' <<< "$BODY" \
|| fail "D6: file response missing <d:getcontentlength>"
# The resourcetype on a file is empty `<d:resourcetype/>` or
# `<d:resourcetype></d:resourcetype>` — NOT a <d:collection/>.
grep -q '<d:collection/>' <<< "$BODY" \
&& fail "D6: file response erroneously contains <d:collection/>"
pass "D6: file Depth:0 — href no trailing slash, content-length present, no collection"
# ─────────────────────────────────────────────────────────────
# D5 — PROPFIND non-existent path → 404
# ─────────────────────────────────────────────────────────────
echo " D5: PROPFIND non-existent path → 404"
STATUS=$(nc_curl -o /dev/null -w "%{http_code}" -X PROPFIND -H "Depth: 0" \
"$NC_FILES_BASE/this-path-does-not-exist-$(date +%s)")
[[ "$STATUS" == "404" ]] \
|| fail "D5: non-existent path expected 404, got $STATUS"
pass "D5: non-existent path returns 404"
# ─────────────────────────────────────────────────────────────
# D7 — PROPFIND Depth: infinity behaviour
#
# OxiCloud's NC PROPFIND streaming handler treats `Depth: infinity`
# the same as `Depth: 1` (the branch is literally
# `if depth != "0" { … one level … }` — see
# `interfaces/nextcloud/webdav_handler.rs::build_nc_streaming_propfind`).
# No recursive descent. This is a deliberate implementation choice:
# many DAV servers either cap or 403 `Depth: infinity` because a
# full tree walk on a large account can be O(filesystem) work
# behind a single HTTP request (RFC 4918 §9.1 explicitly allows
# servers to refuse it with `propfind-finite-depth`).
#
# The test pins this: `Depth: infinity` on /sub-d/ returns the
# same 5 responses as `Depth: 1` (sub-d itself + 2 files + 2
# sub-subfolders), with the nested gamma.txt NOT present. If
# OxiCloud later starts honouring infinity (full descent → 6
# responses including gamma.txt) or refusing it (403), this
# assertion catches the change.
#
# Trailing-slash semantics still apply to whatever IS returned.
# ─────────────────────────────────────────────────────────────
echo " D7: PROPFIND Depth: infinity on /sub-d/ (pinned to current behaviour)"
BODY=$(nc_curl -X PROPFIND -H "Depth: infinity" "$NC_FILES_BASE/sub-d/")
N=$(count_responses "$BODY")
[[ "$N" == "5" ]] \
|| fail "D7: Depth:infinity on sub-d expected 5 responses (treated as Depth:1), got $N"
GAMMA_HREF=$(extract_href_for "$BODY" "/sub-d/deepest1/gamma.txt")
[[ -z "$GAMMA_HREF" ]] \
|| fail "D7: Depth:infinity unexpectedly returned the nested gamma.txt — server now doing recursive descent? Update this test."
assert_collection_hrefs_have_trailing_slash "$BODY" "D7"
pass "D7: Depth:infinity behaves as Depth:1 (5 responses, no nested descent)"
# ─────────────────────────────────────────────────────────────
# D11 — Malformed PROPFIND body → 400
# ─────────────────────────────────────────────────────────────
echo " D11: malformed PROPFIND XML body → 400"
STATUS=$(nc_curl -o /dev/null -w "%{http_code}" -X PROPFIND -H "Depth: 0" \
-H "Content-Type: application/xml" \
--data-binary '<d:propfind xmlns:d="DAV:"><d:prop><d:displayname' \
"$NC_FILES_BASE/")
[[ "$STATUS" == "400" ]] \
|| fail "D11: malformed PROPFIND body expected 400, got $STATUS"
pass "D11: malformed XML rejected with 400"
# ─────────────────────────────────────────────────────────────
# D3 — Empty-home Depth: 1
# Move fixtures to trash + empty trash, then verify only
# the OWN-collection response comes back.
# ─────────────────────────────────────────────────────────────
echo " D3: PROPFIND Depth: 1 on EMPTY home (after fixture cleanup)"
api_delete_folder "$SUB_D_FOLDER_ID"
api_delete_folder "$SUB_EXTRA_FOLDER_ID"
api_delete_file "$HOME_ALPHA_FILE_ID"
api_empty_trash
BODY=$(nc_curl -X PROPFIND -H "Depth: 1" "$NC_FILES_BASE/")
N=$(count_responses "$BODY")
[[ "$N" == "1" ]] \
|| fail "D3: empty home Depth:1 expected 1 response (collection only), got $N"
HOME_OWN_HREF=$(extract_href_for "$BODY" "/dav/files/$username/")
[[ -n "$HOME_OWN_HREF" ]] && [[ "$HOME_OWN_HREF" == */ ]] \
|| fail "D3: empty-home OWN href missing trailing slash: '$HOME_OWN_HREF'"
pass "D3: empty home Depth:1 — only the collection itself"
# ── summary ───────────────────────────────────────────────────────────────────
echo
echo "Results: $PASS passed, $FAIL failed."
[[ "$FAIL" -eq 0 ]] && echo "All tests passed." || exit 1