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 │
└──────────┴─────────┴────────────────────────────────────────────────────────────────────────────────────────────────────┘
This commit is contained in:
Executable
+312
@@ -0,0 +1,312 @@
|
||||
#!/usr/bin/env bash
|
||||
# Shared WebDAV / NC test helpers.
|
||||
#
|
||||
# Source order from a test_*.sh:
|
||||
#
|
||||
# source test.env
|
||||
# source common.sh
|
||||
# source lib/dav_helpers.sh
|
||||
#
|
||||
# Depends on the following globals being already set:
|
||||
# $base_url, $username, $email, $password (from test.env)
|
||||
# $TOKEN (from `oxicloud_login`)
|
||||
#
|
||||
# Sets / exports:
|
||||
# $APP_PASS (from `mint_app_password`)
|
||||
#
|
||||
# Functions provided:
|
||||
# mint_app_password — mints an NC-compatible app password
|
||||
# using the JWT and stores it in $APP_PASS
|
||||
# nc_curl … — `curl` wrapper with Basic Auth pinned to
|
||||
# the admin app password
|
||||
# api_curl … — `curl` wrapper with the JWT bearer
|
||||
# count_responses BODY — count `<d:response>` children in a
|
||||
# multistatus body
|
||||
# extract_href_for BODY SUBPATH
|
||||
# — extract the first `<d:href>` value whose
|
||||
# path ends with SUBPATH
|
||||
# assert_collection_hrefs_have_trailing_slash BODY
|
||||
# — for every `<d:response>` in BODY that
|
||||
# contains `<d:collection/>`, asserts that
|
||||
# the `<d:href>` ends with `/`; otherwise
|
||||
# asserts it does NOT end with `/`. This is
|
||||
# the guard against the past regression where
|
||||
# NC desktop aborted PROPFIND parsing because
|
||||
# a folder href was emitted without trailing
|
||||
# slash (RFC 4918 §5.2).
|
||||
# api_create_folder LABEL PARENT_ID
|
||||
# — POST /api/folders, captures id into $LAST_FOLDER_ID
|
||||
# api_upload_file PATH FOLDER_ID
|
||||
# — POST /api/files/upload, captures id into
|
||||
# $LAST_FILE_ID and the content_hash into
|
||||
# $LAST_FILE_CONTENT_HASH
|
||||
# api_delete_folder ID
|
||||
# — DELETE /api/folders/{id} (soft-delete to trash)
|
||||
# api_empty_trash — DELETE /api/trash/empty
|
||||
|
||||
# Global counters and pass/fail helpers that each test_*.sh may opt
|
||||
# into. Tests that set their own PASS/FAIL can ignore these.
|
||||
PASS=${PASS:-0}
|
||||
FAIL=${FAIL:-0}
|
||||
pass() { PASS=$(( PASS + 1 )); echo " PASS: $*"; }
|
||||
fail() { FAIL=$(( FAIL + 1 )); echo " FAIL: $*" >&2; exit 1; }
|
||||
|
||||
mint_app_password() {
|
||||
local response
|
||||
response=$(curl -s -X POST \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"label\":\"$(basename "${BASH_SOURCE[1]:-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)"
|
||||
}
|
||||
|
||||
# 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.
|
||||
nc_curl() {
|
||||
curl -s -u "$username:$APP_PASS" "$@"
|
||||
}
|
||||
|
||||
# REST API curls use the JWT bearer.
|
||||
api_curl() {
|
||||
curl -s -H "Authorization: Bearer $TOKEN" "$@"
|
||||
}
|
||||
|
||||
# Native `/webdav/...` surface uses JWT bearer (same as REST), in
|
||||
# contrast to the NC `/remote.php/dav/files/...` surface which uses
|
||||
# Basic Auth with an app password (`nc_curl`). Same auth pattern as
|
||||
# `tests/webdav/test_dedup_webdav_multichunk.sh`'s inline
|
||||
# webdav_put/webdav_delete helpers, lifted here so Batch-5+
|
||||
# native-DAV tests can stay DRY.
|
||||
dav_curl() {
|
||||
curl -s -H "Authorization: Bearer $TOKEN" "$@"
|
||||
}
|
||||
|
||||
# Count `<d:response>` (or `<D:response>`) children in a multistatus
|
||||
# body. Case-insensitive on the namespace prefix because OxiCloud's
|
||||
# two DAV surfaces use different cases: the NC handler emits
|
||||
# `<d:response>` (lowercase prefix), while the native `/webdav/`
|
||||
# handler emits `<D:response>` (uppercase prefix). RFC 4918 §14 only
|
||||
# requires the URI to be `"DAV:"` — the prefix label is the
|
||||
# implementer's choice.
|
||||
#
|
||||
# Always exits 0 — `grep -o` returns 1 when nothing matches, which
|
||||
# under `set -euo pipefail` would abort the caller before the count
|
||||
# is even examined. `|| true` here lets the helper return "0" for
|
||||
# an empty / non-multistatus body so the calling test can assert
|
||||
# explicitly on it.
|
||||
count_responses() {
|
||||
{ grep -oiE '<[dD]:response>' <<< "$1" || true; } | wc -l | tr -d ' '
|
||||
}
|
||||
|
||||
# Extract the first `<d:href>` whose path contains the given suffix.
|
||||
# Returns the raw href as-emitted by the server (no URL decoding);
|
||||
# the suffix match is done on the raw form.
|
||||
#
|
||||
# Always exits 0 — empty stdout means "no href matched". Callers
|
||||
# under `set -euo pipefail` rely on this: `VAR=$(extract_href_for …)`
|
||||
# would otherwise abort the whole script when grep finds nothing,
|
||||
# which is wrong for tests that legitimately want to assert
|
||||
# "this href is absent" (D7 pinning Depth:infinity = Depth:1).
|
||||
extract_href_for() {
|
||||
local body="$1" suffix="$2"
|
||||
# `grep -F … || true` swallows the no-match exit code (1) while
|
||||
# preserving real errors (2 → propagates because the outer
|
||||
# pipeline still has pipefail visibility into earlier stages).
|
||||
# Case-insensitive on the prefix — see `count_responses`.
|
||||
grep -oiE '<[dD]:href>[^<]+</[dD]:href>' <<< "$body" \
|
||||
| sed -E 's|^<[dD]:href>([^<]+)</[dD]:href>$|\1|' \
|
||||
| { grep -F "$suffix" || true; } \
|
||||
| head -n 1
|
||||
}
|
||||
|
||||
# Extract the `<d:href>` from a `<d:response>` block whose body
|
||||
# contains the given substring anywhere (e.g. an
|
||||
# `<nc:trashbin-filename>` for trashbin responses, where the href
|
||||
# itself only carries the opaque trash UUID — the filename only
|
||||
# appears in the nc-namespace elements).
|
||||
#
|
||||
# Returns the raw href, or empty when no block matches. Same
|
||||
# `pipefail`-safe semantics as `extract_href_for`.
|
||||
extract_response_href_containing() {
|
||||
local body="$1" needle="$2"
|
||||
# Case-insensitive on the prefix — see `count_responses`.
|
||||
awk -v needle="$needle" '
|
||||
BEGIN { RS="</[dD]:response>" }
|
||||
index($0, needle) > 0 && match($0, /<[dD]:href>[^<]+<\/[dD]:href>/) {
|
||||
m = substr($0, RSTART, RLENGTH)
|
||||
sub(/^<[dD]:href>/, "", m)
|
||||
sub(/<\/[dD]:href>$/, "", m)
|
||||
print m
|
||||
exit
|
||||
}
|
||||
' <<< "$body"
|
||||
}
|
||||
|
||||
# Validate trailing-slash semantics across every <d:response> in a
|
||||
# multistatus body. The function walks the body once and, per
|
||||
# response, asserts:
|
||||
#
|
||||
# - if <d:resourcetype> contains <d:collection/>, the matching
|
||||
# <d:href> MUST end with '/'
|
||||
# - otherwise the <d:href> MUST NOT end with '/'
|
||||
#
|
||||
# Exits via `fail` on the first violation. This is the regression
|
||||
# guard for the NC desktop "Invalid href" parse error.
|
||||
assert_collection_hrefs_have_trailing_slash() {
|
||||
local body="$1" label="${2:-multistatus}"
|
||||
# Use awk's RS to chunk the body by </d:response> or </D:response>
|
||||
# (case-insensitive on the prefix — see `count_responses` for why
|
||||
# both casings matter). For each chunk, find the href and check
|
||||
# for the collection marker.
|
||||
local pairs
|
||||
pairs=$(awk '
|
||||
BEGIN { RS = "</[dD]:response>" }
|
||||
/<[dD]:response>/ {
|
||||
href = ""
|
||||
is_coll = 0
|
||||
if (match($0, /<[dD]:href>[^<]+<\/[dD]:href>/)) {
|
||||
m = substr($0, RSTART, RLENGTH)
|
||||
# Strip the surrounding <d:href>/</d:href> tags
|
||||
# (either case).
|
||||
sub(/^<[dD]:href>/, "", m)
|
||||
sub(/<\/[dD]:href>$/, "", m)
|
||||
href = m
|
||||
}
|
||||
if ($0 ~ /<[dD]:collection\/>/) is_coll = 1
|
||||
if (href != "") print is_coll "|" href
|
||||
}
|
||||
' <<< "$body")
|
||||
|
||||
local line is_coll href violation=0
|
||||
while IFS='|' read -r is_coll href; do
|
||||
[[ -z "$href" ]] && continue
|
||||
if [[ "$is_coll" == "1" ]]; then
|
||||
if [[ "$href" != */ ]]; then
|
||||
echo " FAIL[$label]: collection href without trailing slash: '$href'" >&2
|
||||
violation=1
|
||||
fi
|
||||
else
|
||||
if [[ "$href" == */ ]]; then
|
||||
echo " FAIL[$label]: non-collection href with trailing slash: '$href'" >&2
|
||||
violation=1
|
||||
fi
|
||||
fi
|
||||
done <<< "$pairs"
|
||||
if [[ "$violation" -ne 0 ]]; then
|
||||
fail "[$label] trailing-slash semantics violated (see above)"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── REST API setup helpers ────────────────────────────────────────────────────
|
||||
|
||||
# POST /api/folders. Reads:
|
||||
# $1 — folder name
|
||||
# $2 — parent folder id (optional; passed verbatim as parent_id)
|
||||
# Sets:
|
||||
# $LAST_FOLDER_ID — id of the new folder
|
||||
api_create_folder() {
|
||||
local name="$1" parent_id="${2:-}"
|
||||
local body
|
||||
if [[ -n "$parent_id" ]]; then
|
||||
body=$(jq -n --arg n "$name" --arg p "$parent_id" \
|
||||
'{name:$n, parent_id:$p}')
|
||||
else
|
||||
body=$(jq -n --arg n "$name" '{name:$n}')
|
||||
fi
|
||||
local response
|
||||
# `/api/folders/` is the SINGLE-folder create (CreateFolderDto).
|
||||
# `/api/folders/create` is the BATCH endpoint — different DTO,
|
||||
# accepts an array; using it here returns 422 with an empty body
|
||||
# (which is what the original version of this helper was hitting
|
||||
# and failing on).
|
||||
response=$(api_curl -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$body" \
|
||||
"$base_url/api/folders")
|
||||
LAST_FOLDER_ID=$(jq -r '.id // empty' <<< "$response")
|
||||
[[ -n "$LAST_FOLDER_ID" ]] || fail "api_create_folder '$name': no id (response: $response)"
|
||||
}
|
||||
|
||||
# POST /api/files/upload (multipart). Reads:
|
||||
# $1 — local fixture path
|
||||
# $2 — folder id (the file will be uploaded into this folder)
|
||||
# Sets:
|
||||
# $LAST_FILE_ID — id of the new file
|
||||
# $LAST_FILE_CONTENT_HASH — content_hash from the response
|
||||
api_upload_file() {
|
||||
local fixture="$1" folder_id="$2"
|
||||
local response
|
||||
response=$(api_curl -X POST \
|
||||
-F "folder_id=$folder_id" \
|
||||
-F "file=@$fixture" \
|
||||
"$base_url/api/files/upload")
|
||||
LAST_FILE_ID=$(jq -r '.id // empty' <<< "$response")
|
||||
LAST_FILE_CONTENT_HASH=$(jq -r '.content_hash // empty' <<< "$response")
|
||||
[[ -n "$LAST_FILE_ID" ]] || fail "api_upload_file '$fixture': no id (response: $response)"
|
||||
}
|
||||
|
||||
api_delete_folder() {
|
||||
local id="$1"
|
||||
api_curl -X DELETE "$base_url/api/folders/$id" > /dev/null
|
||||
}
|
||||
|
||||
api_delete_file() {
|
||||
local id="$1"
|
||||
api_curl -X DELETE "$base_url/api/files/$id" > /dev/null
|
||||
}
|
||||
|
||||
api_empty_trash() {
|
||||
api_curl -X DELETE "$base_url/api/trash/empty" > /dev/null
|
||||
}
|
||||
|
||||
# Wipe every child of the user's home folder + empty the trash, via
|
||||
# the REST API. The home folder itself is preserved (it's a root
|
||||
# folder, untouchable anyway).
|
||||
#
|
||||
# Useful as a defensive `wipe_home_folder` call at the START of any
|
||||
# test that depends on a clean home state, so cross-test
|
||||
# contamination from earlier scripts (e.g. orphans left by handlers
|
||||
# that 500-leak on conflict, or pinned-bug scenarios that
|
||||
# deliberately leave half-cleaned state) never poisons later
|
||||
# assertions. Requires `$HOME_FOLDER_ID` to be set first via
|
||||
# `resolve_home_folder_id`.
|
||||
wipe_home_folder() {
|
||||
[[ -n "${HOME_FOLDER_ID:-}" ]] \
|
||||
|| fail "wipe_home_folder: HOME_FOLDER_ID is unset — call resolve_home_folder_id first"
|
||||
# `/listing` (NOT `/contents`) is the endpoint that returns the
|
||||
# `.files[]` / `.folders[]` arrays we iterate here — same one
|
||||
# `tests/api/storage_cleanup_check.sh` uses for the equivalent
|
||||
# full-tree wipe before its disk-audit step.
|
||||
local listing
|
||||
listing=$(api_curl "$base_url/api/folders/$HOME_FOLDER_ID/listing")
|
||||
# Delete every direct child file (recursive contents go with the
|
||||
# file's row). Errors are swallowed because the test that called
|
||||
# us doesn't care WHY a leftover was unreachable — it just wants
|
||||
# the slate clean.
|
||||
while IFS= read -r fid; do
|
||||
[[ -z "$fid" || "$fid" == "null" ]] && continue
|
||||
api_curl -X DELETE "$base_url/api/files/$fid" > /dev/null 2>&1 || true
|
||||
done < <(jq -r '.files[]?.id // empty' <<< "$listing")
|
||||
# Then every direct child folder (recursive subtree goes with).
|
||||
while IFS= read -r fid; do
|
||||
[[ -z "$fid" || "$fid" == "null" ]] && continue
|
||||
api_curl -X DELETE "$base_url/api/folders/$fid" > /dev/null 2>&1 || true
|
||||
done < <(jq -r '.folders[]?.id // empty' <<< "$listing")
|
||||
# Finally permanently delete everything in trash so the row-level
|
||||
# `is_trashed` orphans the upstream tests left behind don't make
|
||||
# *us* leak chunks/blobs into storage_cleanup_check's audit.
|
||||
api_empty_trash
|
||||
}
|
||||
|
||||
# Resolve the user's home folder id (parent_id IS NULL, first entry).
|
||||
# Sets:
|
||||
# $HOME_FOLDER_ID
|
||||
resolve_home_folder_id() {
|
||||
local response
|
||||
response=$(api_curl "$base_url/api/folders")
|
||||
HOME_FOLDER_ID=$(jq -r '.[0].id // empty' <<< "$response")
|
||||
[[ -n "$HOME_FOLDER_ID" ]] || fail "Could not resolve home folder id (response: $response)"
|
||||
}
|
||||
+6
-1
@@ -62,7 +62,12 @@ OXICLOUD_SERVER_PORT=$SERVER_PORT
|
||||
OXICLOUD_STORAGE_PATH="$REPO_ROOT/tests/api/storage"
|
||||
set +a
|
||||
|
||||
mkdir -p "$OXICLOUD_STORAGE_PATH"
|
||||
# ensure storage is empty before starting (regex-gated rm -rf).
|
||||
# Previously this script only ran `mkdir -p`, so a standalone webdav run
|
||||
# inherited state from a prior api run — now both runners wipe uniformly.
|
||||
# shellcheck source=../common/wipe-storage.sh
|
||||
source "$COMMON/wipe-storage.sh"
|
||||
wipe_storage "$OXICLOUD_STORAGE_PATH"
|
||||
|
||||
# ── 3. Start OxiCloud server ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
Executable
+396
@@ -0,0 +1,396 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================
|
||||
# OxiCloud — Baseline: Native /webdav/ + LOCK / UNLOCK
|
||||
# =============================================================
|
||||
# Groups M + N from BASELINE_TESTS_NC_WEBDAV.md (11 scenarios).
|
||||
#
|
||||
# The native `/webdav/...` surface is the protocol layer
|
||||
# rclone, davfs2, Cyberduck, Office (via WebDAV mount), and
|
||||
# the other generic-DAV ecosystem use. It differs from the
|
||||
# NC `/remote.php/dav/files/{user}/...` surface in several
|
||||
# baseline-worthy ways:
|
||||
#
|
||||
# - Auth: JWT bearer (not Basic Auth with an app password)
|
||||
# - Chroot: implicit "the user's home folder", NO {user}
|
||||
# URL segment to validate
|
||||
# - DAV class advertisement: `1, 2` (incl. Class 2 LOCK)
|
||||
# versus NC's `1, 3`
|
||||
#
|
||||
# Coverage:
|
||||
# M1 — OPTIONS / advertises DAV 1, 2 + Allow includes LOCK
|
||||
# M2 — PROPFIND Depth: 1 trailing-slash semantics (the
|
||||
# same regression guard as D9/D10 on the NC surface,
|
||||
# run again on this surface)
|
||||
# M3 — PUT sample.txt → 201
|
||||
# M4 — Range GET (bytes=0-9) → 206
|
||||
# M5 — MOVE sample.txt → moved.txt
|
||||
# M6 — MKCOL sub/ → 201
|
||||
# M7 — DELETE sub/ → 204
|
||||
# M8 — COPY a.txt → b.txt (pin whatever current is — native
|
||||
# COPY may or may not be implemented)
|
||||
# N1 — LOCK locked.txt → 200 + Lock-Token header
|
||||
# N2 — PUT locked.txt without If: <token> from a different
|
||||
# context → 423 Locked
|
||||
# N3 — UNLOCK with the token → 204; subsequent PUT succeeds
|
||||
# =============================================================
|
||||
|
||||
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 "=== Native /webdav/ + LOCK / UNLOCK (Groups M + N baseline) ==="
|
||||
echo
|
||||
|
||||
oxicloud_login
|
||||
resolve_home_folder_id
|
||||
wipe_home_folder
|
||||
|
||||
DAV_BASE="$base_url/webdav"
|
||||
FIXTURE_DIR=$(mktemp -d)
|
||||
trap 'rm -rf "$FIXTURE_DIR"; wipe_home_folder 2>/dev/null || true' EXIT
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# M1 — OPTIONS advertises DAV 1, 2 + Allow includes LOCK
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
echo " M1: OPTIONS /webdav/ → DAV: 1, 2 + Allow includes LOCK/UNLOCK"
|
||||
HEADERS=$(dav_curl -i -X OPTIONS "$DAV_BASE/" | tr -d '\r')
|
||||
STATUS=$(awk 'NR==1{print $2}' <<< "$HEADERS")
|
||||
[[ "$STATUS" == "200" ]] \
|
||||
|| fail "M1: OPTIONS expected 200, got $STATUS"
|
||||
grep -qi '^dav:.*1.*2' <<< "$HEADERS" \
|
||||
|| fail "M1: missing 'DAV: 1, 2' header (native surface should advertise Class 2)"
|
||||
grep -qi '^allow:.*LOCK' <<< "$HEADERS" || fail "M1: Allow missing LOCK"
|
||||
grep -qi '^allow:.*UNLOCK' <<< "$HEADERS" || fail "M1: Allow missing UNLOCK"
|
||||
pass "M1: OPTIONS advertises DAV 1, 2 + Allow includes LOCK/UNLOCK"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Fixture setup via REST so M2 has stable mixed content.
|
||||
# We deliberately PROPFIND a dedicated *sub-folder* of home,
|
||||
# not bare `/webdav/`, because:
|
||||
#
|
||||
# 1. The native handler's `resolve_webdav_path` only fires
|
||||
# when the URL subpath is non-empty (gated by
|
||||
# `!path.is_empty() && method.as_str() != "OPTIONS"`).
|
||||
# PROPFIND on bare `/webdav/` therefore returns the
|
||||
# user's root-collections list, not the contents of
|
||||
# their home folder.
|
||||
# 2. The number of root collections varies per environment
|
||||
# (default home + anything else the system or earlier
|
||||
# tests created), so asserting a fixed count there is
|
||||
# brittle. A test-owned sub-folder is fully under our
|
||||
# control.
|
||||
#
|
||||
# Inside `m2-probe/`: 2 files + 2 sub-folders → PROPFIND
|
||||
# Depth: 1 yields exactly 5 responses (self + 4 children) and
|
||||
# exercises both the file-href (no trailing slash) and the
|
||||
# folder-href (trailing slash) branches of the same
|
||||
# regression guard that catches D9/D10 on the NC surface.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
api_create_folder "m2-probe" "$HOME_FOLDER_ID"
|
||||
M2_PROBE_ID="$LAST_FOLDER_ID"
|
||||
echo "alpha" > "$FIXTURE_DIR/m2-alpha.txt"
|
||||
echo "beta" > "$FIXTURE_DIR/m2-beta.txt"
|
||||
api_upload_file "$FIXTURE_DIR/m2-alpha.txt" "$M2_PROBE_ID"
|
||||
api_upload_file "$FIXTURE_DIR/m2-beta.txt" "$M2_PROBE_ID"
|
||||
api_create_folder "m2-foldA" "$M2_PROBE_ID"
|
||||
api_create_folder "m2-foldB" "$M2_PROBE_ID"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# M2 — PROPFIND Depth: 1 trailing-slash semantics (mixed)
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
echo " M2: PROPFIND /webdav/m2-probe/ Depth: 1 (mixed children — trailing-slash regression guard)"
|
||||
BODY=$(dav_curl -X PROPFIND -H "Depth: 1" "$DAV_BASE/m2-probe/")
|
||||
N=$(count_responses "$BODY")
|
||||
# Collection (1) + 2 files + 2 folders = 5
|
||||
[[ "$N" == "5" ]] \
|
||||
|| fail "M2: expected 5 responses (collection + 2 files + 2 folders), got $N"
|
||||
assert_collection_hrefs_have_trailing_slash "$BODY" "M2"
|
||||
pass "M2: 5 responses, trailing-slash semantics correct on native /webdav/ surface"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# M3 — PUT a new file
|
||||
#
|
||||
# Pinned current behaviour: the native handler always returns
|
||||
# 204 NO_CONTENT regardless of new-vs-overwrite. The NC handler
|
||||
# differentiates (201 for new, 204 for overwrite, see F1/F3)
|
||||
# but the native one in `interfaces/api/handlers/webdav_handler.rs::handle_put`
|
||||
# unconditionally builds a 204 response on success (line ~1026
|
||||
# at time of writing). RFC 4918 §9.7.1 actually allows either
|
||||
# — both indicate success — so this is current behaviour, not
|
||||
# a bug. NC desktop / generic DAV clients accept both.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# M3-M8 use ROOT-level paths (just `/webdav/<name>`) NOT nested
|
||||
# under `m2-probe/`. Why: there's a real bug in the native PUT
|
||||
# handler where a PUT to `/webdav/m2-probe/foo.txt` writes the
|
||||
# file with `folder_id=NULL` (`get_parent_folder_id` doesn't
|
||||
# correctly look up REST-created parent folders), so the file
|
||||
# effectively ends up at root. The lenient GET path
|
||||
# (`get_file_by_path`) still finds it, but the strict
|
||||
# `resolve_path_for_user` (which MOVE / COPY / DELETE use) does
|
||||
# not. PUT then GET works on nested paths; PUT then MOVE 404s.
|
||||
# Pinning this as KNOWN BUG at M5 below.
|
||||
#
|
||||
# Until that's fixed, M3-M8 use root-level paths so the rest of
|
||||
# the lifecycle (which the existing test_dedup_webdav_* scripts
|
||||
# also exercise at root) actually validates.
|
||||
|
||||
echo " M3: PUT /webdav/m3-sample.txt (pinned: native always 204, NC would be 201 on new)"
|
||||
STATUS=$(dav_curl -o /dev/null -w "%{http_code}" -X PUT \
|
||||
-H "Content-Type: text/plain" \
|
||||
--data-binary 'sample contents — exactly 31 bytes' \
|
||||
"$DAV_BASE/m3-sample.txt")
|
||||
case "$STATUS" in
|
||||
204)
|
||||
pass "M3: native PUT new → 204 (pinned current behaviour; differs from NC's 201/204 split)"
|
||||
;;
|
||||
201)
|
||||
fail "M3: native PUT now returns 201 for new — handler differentiates new-vs-overwrite. Update pin if intentional."
|
||||
;;
|
||||
*)
|
||||
fail "M3: unexpected status $STATUS"
|
||||
;;
|
||||
esac
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# M4 — Range GET bytes=0-9 → 206 + 10 bytes
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
echo " M4: GET /webdav/m3-sample.txt with Range: bytes=0-9 → 206 + 10 bytes"
|
||||
HEADERS=$(dav_curl -D - -o /dev/null -H "Range: bytes=0-9" "$DAV_BASE/m3-sample.txt")
|
||||
STATUS=$(awk 'NR==1{print $2}' <<< "$HEADERS" | tr -d '\r')
|
||||
[[ "$STATUS" == "206" ]] \
|
||||
|| fail "M4: Range GET expected 206, got $STATUS"
|
||||
BODY_SIZE=$(dav_curl -H "Range: bytes=0-9" "$DAV_BASE/m3-sample.txt" | wc -c | tr -d ' ')
|
||||
[[ "$BODY_SIZE" == "10" ]] \
|
||||
|| fail "M4: Range body size expected 10, got $BODY_SIZE"
|
||||
pass "M4: Range bytes=0-9 → 206 + 10 bytes"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# M5 — MOVE sample.txt → moved.txt
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# M5 — root-level MOVE
|
||||
#
|
||||
# After all the nested-path diagnostics above (which surfaced the
|
||||
# bug pinned in the M3 comment), this assertion finally tests the
|
||||
# code path where it should actually work: root-level MOVE of a
|
||||
# file PUT at root. If even this 404s, the bug is broader and
|
||||
# native MOVE is unusable, not just nested.
|
||||
echo " M5: MOVE /webdav/m3-sample.txt → /webdav/m5-moved.txt"
|
||||
STATUS=$(dav_curl -o /dev/null -w "%{http_code}" -X MOVE \
|
||||
-H "Destination: $DAV_BASE/m5-moved.txt" \
|
||||
"$DAV_BASE/m3-sample.txt")
|
||||
case "$STATUS" in
|
||||
201|204)
|
||||
pass "M5: root-level MOVE → $STATUS"
|
||||
;;
|
||||
404)
|
||||
# KNOWN BUG: native MOVE returns 404 on a file that was
|
||||
# PUT at the same path, even at root level. The strict
|
||||
# `resolve_path_for_user` SQL query doesn't match what
|
||||
# the PUT's `save_file_from_temp_with_dedup` stored —
|
||||
# most likely because the WebDAV dispatcher's path
|
||||
# prepending (`resolve_webdav_path` → "My Folder - X/foo")
|
||||
# doesn't match the user's actual home folder path
|
||||
# field in the DB. Same root cause makes nested MOVE
|
||||
# (see M3 comment) unusable too.
|
||||
#
|
||||
# Where the fix lives:
|
||||
# `interfaces/api/handlers/webdav_handler.rs::handle_move`
|
||||
# currently calls `resolver.resolve_path_for_user`. It
|
||||
# should either:
|
||||
# (a) fall back to `file_retrieval_service.get_file_by_path`
|
||||
# (the same lookup GET uses successfully), or
|
||||
# (b) normalise the source path through the same
|
||||
# transformer the PUT writes through.
|
||||
pass "M5: root-level MOVE → 404 (KNOWN BUG: resolve_path_for_user mismatch — pinned)"
|
||||
;;
|
||||
*)
|
||||
fail "M5: unexpected status $STATUS"
|
||||
;;
|
||||
esac
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# M6 — MKCOL sub/ → 201
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
echo " M6: MKCOL /webdav/m6-sub/ → 201"
|
||||
STATUS=$(dav_curl -o /dev/null -w "%{http_code}" -X MKCOL "$DAV_BASE/m6-sub/")
|
||||
[[ "$STATUS" == "201" ]] \
|
||||
|| fail "M6: MKCOL expected 201, got $STATUS"
|
||||
pass "M6: native MKCOL → 201"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# M7 — DELETE sub/ → 204
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
echo " M7: DELETE /webdav/m6-sub/ → 204"
|
||||
STATUS=$(dav_curl -o /dev/null -w "%{http_code}" -X DELETE "$DAV_BASE/m6-sub/")
|
||||
case "$STATUS" in
|
||||
204) pass "M7: native DELETE → 204" ;;
|
||||
404)
|
||||
# If DELETE also hits the resolve_path_for_user 404 trap
|
||||
# (it uses the same resolver), pin as same root-cause
|
||||
# KNOWN BUG.
|
||||
pass "M7: native DELETE → 404 (KNOWN BUG: same resolve_path_for_user mismatch as M5 — pinned)"
|
||||
;;
|
||||
*) fail "M7: unexpected status $STATUS" ;;
|
||||
esac
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# M8 — COPY a.txt → b.txt (pin whatever current behaviour is)
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# M8 source depends on whether M5 MOVE actually worked. If M5 was
|
||||
# pinned as KNOWN BUG (404), the source for M8 is still
|
||||
# m3-sample.txt at root, not m5-moved.txt.
|
||||
echo " M8: COPY native source → /webdav/m8-copy.txt"
|
||||
M8_SOURCE_URL="$DAV_BASE/m3-sample.txt"
|
||||
# If M5 actually moved the file, the source name changed.
|
||||
if dav_curl -o /dev/null -w "%{http_code}" -X PROPFIND -H "Depth: 0" "$DAV_BASE/m5-moved.txt" | grep -q "207"; then
|
||||
M8_SOURCE_URL="$DAV_BASE/m5-moved.txt"
|
||||
fi
|
||||
STATUS=$(dav_curl -o /dev/null -w "%{http_code}" -X COPY \
|
||||
-H "Destination: $DAV_BASE/m8-copy.txt" \
|
||||
"$M8_SOURCE_URL")
|
||||
case "$STATUS" in
|
||||
201|204)
|
||||
# Confirm source still exists (COPY != MOVE).
|
||||
SRC_STATUS=$(dav_curl -o /dev/null -w "%{http_code}" -X PROPFIND -H "Depth: 0" "$M8_SOURCE_URL")
|
||||
DST_STATUS=$(dav_curl -o /dev/null -w "%{http_code}" -X PROPFIND -H "Depth: 0" "$DAV_BASE/m8-copy.txt")
|
||||
[[ "$SRC_STATUS" == "207" ]] \
|
||||
|| fail "M8: COPY removed source ($SRC_STATUS instead of 207) — that's MOVE behaviour, not COPY"
|
||||
[[ "$DST_STATUS" == "207" ]] \
|
||||
|| fail "M8: destination not present after COPY ($DST_STATUS)"
|
||||
pass "M8: native COPY → $STATUS, source preserved, destination present"
|
||||
;;
|
||||
405)
|
||||
pass "M8: native COPY → 405 METHOD_NOT_ALLOWED — handler not implemented, pinned"
|
||||
;;
|
||||
404)
|
||||
pass "M8: native COPY → 404 (KNOWN BUG: same resolve_path_for_user mismatch as M5/M7 — pinned)"
|
||||
;;
|
||||
500)
|
||||
# KNOWN BUG: the COPY file branch at
|
||||
# `interfaces/api/handlers/webdav_handler.rs::handle_copy`
|
||||
# line ~1639 passes `(file.id, user.id, target_folder_id)`
|
||||
# to `copy_file_with_perms` — no destination NAME. The
|
||||
# copy therefore lands in the target folder under the
|
||||
# SOURCE's name, ignoring the rename the client requested.
|
||||
# When source and destination resolve to the same folder
|
||||
# (common for root-level COPY), this collides with the
|
||||
# source itself → AlreadyExists → leaks as 500.
|
||||
#
|
||||
# Where the fix lives: same handler — either
|
||||
# (a) extend `copy_file_with_perms` to accept an
|
||||
# optional new name (the folder-tree branch on
|
||||
# line ~1591 already passes a name into
|
||||
# `copy_folder_tree_with_perms`), or
|
||||
# (b) follow the copy with a `rename_file_with_perms`
|
||||
# call if `dest_filename != source.name` (mirrors
|
||||
# what MOVE does at line ~1347).
|
||||
pass "M8: native COPY → 500 (KNOWN BUG: dest filename discarded, collides with source — pinned)"
|
||||
;;
|
||||
*)
|
||||
fail "M8: unexpected COPY status $STATUS"
|
||||
;;
|
||||
esac
|
||||
|
||||
# ═════════════════════════════════════════════════════════════
|
||||
# Group N — LOCK / UNLOCK
|
||||
# ═════════════════════════════════════════════════════════════
|
||||
|
||||
# Set up a file for the lock scenarios.
|
||||
dav_curl -o /dev/null -X PUT \
|
||||
-H "Content-Type: text/plain" \
|
||||
--data-binary 'lockable contents' \
|
||||
"$DAV_BASE/n-locked.txt" > /dev/null
|
||||
|
||||
LOCK_BODY='<?xml version="1.0" encoding="utf-8"?>
|
||||
<d:lockinfo xmlns:d="DAV:">
|
||||
<d:lockscope><d:exclusive/></d:lockscope>
|
||||
<d:locktype><d:write/></d:locktype>
|
||||
<d:owner>baseline-test-owner</d:owner>
|
||||
</d:lockinfo>'
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# N1 — LOCK → 200 with Lock-Token header
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
echo " N1: LOCK /webdav/n-locked.txt → 200 + Lock-Token header"
|
||||
HEADERS=$(dav_curl -D - -o /dev/null -X LOCK \
|
||||
-H "Content-Type: application/xml" \
|
||||
-H "Timeout: Second-60" \
|
||||
--data "$LOCK_BODY" \
|
||||
"$DAV_BASE/n-locked.txt")
|
||||
STATUS=$(awk 'NR==1{print $2}' <<< "$HEADERS" | tr -d '\r')
|
||||
[[ "$STATUS" == "200" ]] \
|
||||
|| fail "N1: LOCK expected 200, got $STATUS"
|
||||
LOCK_TOKEN=$(grep -i '^lock-token:' <<< "$HEADERS" | awk '{print $2}' | tr -d '\r<>')
|
||||
[[ -n "$LOCK_TOKEN" ]] \
|
||||
|| fail "N1: LOCK response missing Lock-Token header"
|
||||
pass "N1: LOCK → 200 + Lock-Token=$LOCK_TOKEN"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# N2 — PUT without the lock token → 423 Locked
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# N2 — PUT to a locked file without the token
|
||||
#
|
||||
# RFC 4918 §9.10.4 + §6: a writeable resource under an
|
||||
# exclusive lock MUST reject conflicting writes with 423
|
||||
# Locked. OxiCloud's native handler currently does NOT consult
|
||||
# the lock store before writing — LOCK just produces a token,
|
||||
# and any PUT/DELETE/MOVE/PROPPATCH succeeds regardless. The
|
||||
# class-2 DAV advertisement in M1 is therefore aspirational:
|
||||
# the protocol surface exists, the enforcement doesn't.
|
||||
#
|
||||
# Where the fix lives:
|
||||
# `interfaces/api/handlers/webdav_handler.rs::handle_put` (and
|
||||
# the mutator paths in handle_delete / handle_move / handle_copy /
|
||||
# handle_proppatch) — each needs to check the WebDAV lock service
|
||||
# for an active lock on the target path and reject with 423 if
|
||||
# the request doesn't carry a matching `If: (<token>)` header.
|
||||
# The lock store itself already records tokens — confirmed by N1
|
||||
# capturing one — so the gap is purely on the read-side check.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
echo " N2: PUT /webdav/n-locked.txt without If:(<token>) — pinned: lock not enforced (RFC would 423)"
|
||||
STATUS=$(dav_curl -o /dev/null -w "%{http_code}" -X PUT \
|
||||
-H "Content-Type: text/plain" \
|
||||
--data-binary 'tampered contents' \
|
||||
"$DAV_BASE/n-locked.txt")
|
||||
case "$STATUS" in
|
||||
204)
|
||||
pass "N2: PUT succeeded despite active lock → 204 (KNOWN BUG: lock not enforced — pinned)"
|
||||
;;
|
||||
423)
|
||||
fail "N2: server now returns 423 Locked. Lock enforcement was added — update this pin to assert == 423."
|
||||
;;
|
||||
*)
|
||||
fail "N2: unexpected status $STATUS"
|
||||
;;
|
||||
esac
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# N3 — UNLOCK with token → 204; subsequent PUT succeeds
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
echo " N3: UNLOCK /webdav/n-locked.txt + follow-up PUT succeeds"
|
||||
STATUS=$(dav_curl -o /dev/null -w "%{http_code}" -X UNLOCK \
|
||||
-H "Lock-Token: <$LOCK_TOKEN>" \
|
||||
"$DAV_BASE/n-locked.txt")
|
||||
[[ "$STATUS" == "204" ]] \
|
||||
|| fail "N3: UNLOCK expected 204, got $STATUS"
|
||||
# Now PUT without any token — should succeed since lock is released.
|
||||
STATUS=$(dav_curl -o /dev/null -w "%{http_code}" -X PUT \
|
||||
-H "Content-Type: text/plain" \
|
||||
--data-binary 'post-unlock contents' \
|
||||
"$DAV_BASE/n-locked.txt")
|
||||
[[ "$STATUS" == "201" || "$STATUS" == "204" ]] \
|
||||
|| fail "N3: post-unlock PUT expected 201/204, got $STATUS"
|
||||
pass "N3: UNLOCK → 204 + subsequent PUT succeeds ($STATUS)"
|
||||
|
||||
# ── summary ───────────────────────────────────────────────────────────────────
|
||||
|
||||
echo
|
||||
echo "Results: $PASS passed, $FAIL failed."
|
||||
[[ "$FAIL" -eq 0 ]] && echo "All tests passed." || exit 1
|
||||
+250
@@ -0,0 +1,250 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================
|
||||
# OxiCloud — Baseline: NC chunked-upload assembly + BLAKE3
|
||||
# =============================================================
|
||||
# Group J from BASELINE_TESTS_NC_WEBDAV.md.
|
||||
#
|
||||
# Headline assertion:
|
||||
# J7 — BLAKE3 round-trip on the ASSEMBLED file. Local b3sum
|
||||
# of `concat(chunk1, chunk2)` must equal the server's
|
||||
# FileDto.content_hash after the chunked-MOVE-to-`.file`
|
||||
# assembly step. Proves the streaming hash-on-write
|
||||
# during chunked assembly produces the canonical BLAKE3
|
||||
# — the same `content_hash` field that F8/F9 validate
|
||||
# for the direct-PUT path. F8/F9 + J7 together cover
|
||||
# every path a file's content_hash gets computed on.
|
||||
#
|
||||
# Scope split vs existing scripts:
|
||||
# - `test_nextcloud_chunked_upload_propfind.sh` already
|
||||
# covers J1-J4 (MKCOL session, PUT chunks, PROPFIND-resume).
|
||||
# - `test_nextcloud_chunked_upload_cap.sh` already covers
|
||||
# J8 (chunk-over-cap → 413).
|
||||
# - This script picks up the rest of the lifecycle:
|
||||
# J5 — MOVE `.file` to destination (assembly)
|
||||
# J6 — GET assembled file: bytes match concat
|
||||
# J7 — BLAKE3 round-trip on assembled (HEADLINE)
|
||||
# J9 — DELETE on a separate session (abort) → 204
|
||||
# J10 — PROPFIND on the J5 session AFTER assembly → 404
|
||||
#
|
||||
# `xq` is used for the J4-style PROPFIND assertions so the
|
||||
# tests can XPath-query namespaced multistatus XML instead of
|
||||
# parsing it with awk/sed. See the install line in
|
||||
# `.github/workflows/ci.yml`.
|
||||
# =============================================================
|
||||
|
||||
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 chunked-upload assembly + BLAKE3 (Group J baseline) ==="
|
||||
echo
|
||||
|
||||
# Preflight: xq required for the multistatus XPath checks. Pinned to
|
||||
# sibprogrammer/xq (Go binary, real XPath via libxml2).
|
||||
command -v xq >/dev/null 2>&1 \
|
||||
|| fail "preflight: xq required (sibprogrammer/xq) — install via 'brew install xq' or the CI release tarball"
|
||||
command -v b3sum >/dev/null 2>&1 \
|
||||
|| fail "preflight: b3sum required for the J7 round-trip — install via 'brew install b3sum' or 'apt install b3sum'"
|
||||
|
||||
oxicloud_login
|
||||
mint_app_password
|
||||
resolve_home_folder_id
|
||||
wipe_home_folder
|
||||
|
||||
NC_FILES_BASE="$base_url/remote.php/dav/files/$username"
|
||||
NC_UPLOAD_BASE="$base_url/remote.php/dav/uploads/$username"
|
||||
|
||||
FIXTURE_DIR=$(mktemp -d)
|
||||
trap 'rm -rf "$FIXTURE_DIR"; \
|
||||
nc_curl -o /dev/null -X DELETE "$NC_FILES_BASE/j-assembled.bin" 2>/dev/null || true; \
|
||||
api_empty_trash 2>/dev/null || true' EXIT
|
||||
|
||||
# ── Fixture: two chunks of known random content ──────────────────────────────
|
||||
# 5 KB + 7 KB — sized to verify the assembly handles unequal-size
|
||||
# chunks correctly (the wire ordering is by chunk number, not size).
|
||||
CHUNK1_PATH="$FIXTURE_DIR/chunk1.bin"
|
||||
CHUNK2_PATH="$FIXTURE_DIR/chunk2.bin"
|
||||
ASSEMBLED_LOCAL="$FIXTURE_DIR/concat.bin"
|
||||
|
||||
dd if=/dev/urandom of="$CHUNK1_PATH" bs=1024 count=5 status=none
|
||||
dd if=/dev/urandom of="$CHUNK2_PATH" bs=1024 count=7 status=none
|
||||
cat "$CHUNK1_PATH" "$CHUNK2_PATH" > "$ASSEMBLED_LOCAL"
|
||||
|
||||
CHUNK1_LEN=$(wc -c < "$CHUNK1_PATH" | tr -d ' ')
|
||||
CHUNK2_LEN=$(wc -c < "$CHUNK2_PATH" | tr -d ' ')
|
||||
ASSEMBLED_LEN=$(wc -c < "$ASSEMBLED_LOCAL" | tr -d ' ')
|
||||
ASSEMBLED_LOCAL_HASH=$(b3sum --no-names "$ASSEMBLED_LOCAL" | awk '{print $1}')
|
||||
|
||||
# Unique session id per run.
|
||||
SESSION_ID="j-assembly-$(date +%s)-$$"
|
||||
SESS_BASE="$NC_UPLOAD_BASE/$SESSION_ID"
|
||||
|
||||
# Defensive idempotent cleanup of any stale session from a previous run.
|
||||
nc_curl -o /dev/null -X DELETE "$SESS_BASE" > /dev/null 2>&1 || true
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Setup — Create the session and PUT both chunks. These mirror
|
||||
# what the dedicated J1-J3 script does; we redo them so this
|
||||
# file is self-contained for the assembly + BLAKE3 checks.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
echo " setup: MKCOL session $SESSION_ID + PUT 2 chunks"
|
||||
|
||||
STATUS=$(nc_curl -o /dev/null -w "%{http_code}" -X MKCOL "$SESS_BASE")
|
||||
[[ "$STATUS" == "201" ]] \
|
||||
|| fail "setup: MKCOL expected 201, got $STATUS"
|
||||
|
||||
STATUS=$(nc_curl -o /dev/null -w "%{http_code}" -X PUT \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary "@$CHUNK1_PATH" \
|
||||
"$SESS_BASE/00000001")
|
||||
[[ "$STATUS" == "201" ]] \
|
||||
|| fail "setup: PUT chunk 00000001 expected 201, got $STATUS"
|
||||
|
||||
STATUS=$(nc_curl -o /dev/null -w "%{http_code}" -X PUT \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary "@$CHUNK2_PATH" \
|
||||
"$SESS_BASE/00000002")
|
||||
[[ "$STATUS" == "201" ]] \
|
||||
|| fail "setup: PUT chunk 00000002 expected 201, got $STATUS"
|
||||
|
||||
pass "setup: session created with 2 chunks ($CHUNK1_LEN + $CHUNK2_LEN bytes)"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# J4-ish sanity check — PROPFIND the session via xq.
|
||||
#
|
||||
# We don't re-test J1-J3 in detail (covered by
|
||||
# test_nextcloud_chunked_upload_propfind.sh), but a single
|
||||
# PROPFIND-via-xq here gives us:
|
||||
# - early failure if the chunks didn't actually land
|
||||
# - smoke test that xq is operational in this environment
|
||||
# before J7 depends on it
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
echo " J4-sanity: PROPFIND session via xq → 3 <d:response> entries"
|
||||
BODY=$(nc_curl -X PROPFIND -H "Depth: 1" "$SESS_BASE")
|
||||
N=$(xq -x 'count(//*[local-name()="response"])' <<< "$BODY" | tr -d '\r\n ')
|
||||
[[ "$N" == "3" ]] \
|
||||
|| fail "J4-sanity: PROPFIND expected 3 responses, got '$N' (body: $BODY)"
|
||||
# Confirm both chunk content-lengths are correct in the XML.
|
||||
CHUNK1_REPORTED=$(xq -x "//*[local-name()='response'][.//*[local-name()='href' and contains(text(), '/00000001')]]//*[local-name()='getcontentlength']/text()" <<< "$BODY" | tr -d '\r\n ')
|
||||
CHUNK2_REPORTED=$(xq -x "//*[local-name()='response'][.//*[local-name()='href' and contains(text(), '/00000002')]]//*[local-name()='getcontentlength']/text()" <<< "$BODY" | tr -d '\r\n ')
|
||||
[[ "$CHUNK1_REPORTED" == "$CHUNK1_LEN" ]] \
|
||||
|| fail "J4-sanity: chunk 00000001 reported $CHUNK1_REPORTED bytes, expected $CHUNK1_LEN"
|
||||
[[ "$CHUNK2_REPORTED" == "$CHUNK2_LEN" ]] \
|
||||
|| fail "J4-sanity: chunk 00000002 reported $CHUNK2_REPORTED bytes, expected $CHUNK2_LEN"
|
||||
pass "J4-sanity: PROPFIND reports 3 responses + correct chunk sizes via xq"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# J5 — MOVE `.file` to destination (assembly)
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
echo " J5: MOVE $SESSION_ID/.file → /j-assembled.bin"
|
||||
HEADERS=$(nc_curl -D - -o /dev/null -X MOVE \
|
||||
-H "Destination: $NC_FILES_BASE/j-assembled.bin" \
|
||||
"$SESS_BASE/.file")
|
||||
STATUS=$(awk 'NR==1{print $2}' <<< "$HEADERS" | tr -d '\r')
|
||||
[[ "$STATUS" == "201" ]] \
|
||||
|| fail "J5: assembly MOVE expected 201, got $STATUS"
|
||||
# ETag + oc-etag headers should be present on the assembly response.
|
||||
grep -qi '^etag:' <<< "$HEADERS" \
|
||||
|| fail "J5: assembly response missing ETag"
|
||||
grep -qi '^oc-etag:' <<< "$HEADERS" \
|
||||
|| fail "J5: assembly response missing oc-etag"
|
||||
pass "J5: assembly MOVE → 201 + ETag/oc-etag headers"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# J6 — GET assembled file: length + bytes match concatenation
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
echo " J6: GET assembled file matches local concat (bytes + length)"
|
||||
ASSEMBLED_REMOTE="$FIXTURE_DIR/assembled-remote.bin"
|
||||
nc_curl -o "$ASSEMBLED_REMOTE" "$NC_FILES_BASE/j-assembled.bin"
|
||||
REMOTE_LEN=$(wc -c < "$ASSEMBLED_REMOTE" | tr -d ' ')
|
||||
[[ "$REMOTE_LEN" == "$ASSEMBLED_LEN" ]] \
|
||||
|| fail "J6: byte count mismatch — remote $REMOTE_LEN vs local $ASSEMBLED_LEN"
|
||||
cmp -s "$ASSEMBLED_LOCAL" "$ASSEMBLED_REMOTE" \
|
||||
|| fail "J6: assembled bytes differ from concat(chunk1, chunk2)"
|
||||
pass "J6: assembled file is byte-identical to concat(chunk1, chunk2)"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# J7 — BLAKE3 round-trip on assembled (HEADLINE)
|
||||
#
|
||||
# REST API exposes FileDto.content_hash (BLAKE3 hex) for every
|
||||
# file row. The server computes this via hash-on-write during
|
||||
# the chunked assembly path — same blake3::Hasher::update
|
||||
# stream as the direct-PUT path, but driven by the chunk
|
||||
# concatenation loop in
|
||||
# `infrastructure/services/nextcloud_chunked_upload_service.rs::assemble`.
|
||||
# Equality with the LOCAL b3sum of the same byte sequence
|
||||
# proves the assembly path produces the canonical BLAKE3 the
|
||||
# dedup / lifecycle hooks downstream key on.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
echo " J7: BLAKE3 round-trip on assembled file (HEADLINE)"
|
||||
# Find the assembled file's id via the REST listing.
|
||||
listing=$(api_curl "$base_url/api/folders/$HOME_FOLDER_ID/listing")
|
||||
ASSEMBLED_ID=$(jq -r '.files[]? | select(.name == "j-assembled.bin") | .id' <<< "$listing")
|
||||
SERVER_HASH=$(jq -r '.files[]? | select(.name == "j-assembled.bin") | .content_hash' <<< "$listing")
|
||||
[[ -n "$ASSEMBLED_ID" && "$ASSEMBLED_ID" != "null" ]] \
|
||||
|| fail "J7: assembled file not visible via REST listing"
|
||||
[[ -n "$SERVER_HASH" && "$SERVER_HASH" != "null" ]] \
|
||||
|| fail "J7: REST listing returned empty content_hash for assembled file"
|
||||
[[ "$SERVER_HASH" == "$ASSEMBLED_LOCAL_HASH" ]] \
|
||||
|| fail "J7: BLAKE3 mismatch — server '$SERVER_HASH' vs local '$ASSEMBLED_LOCAL_HASH' (assembly hash-on-write regression?)"
|
||||
pass "J7: assembled content_hash matches local b3sum ($ASSEMBLED_LOCAL_HASH)"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# J10 — PROPFIND on the session AFTER assembly → 404
|
||||
#
|
||||
# Per the assembly contract, completing the MOVE to `.file`
|
||||
# purges the session. A subsequent PROPFIND on the same
|
||||
# session URL must return 404 (the resume-info is gone). If a
|
||||
# regression starts returning 207 here, NC clients would
|
||||
# loop-retry chunks against an already-assembled file.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
echo " J10: PROPFIND on session AFTER assembly → 404"
|
||||
STATUS=$(nc_curl -o /dev/null -w "%{http_code}" -X PROPFIND -H "Depth: 0" "$SESS_BASE")
|
||||
[[ "$STATUS" == "404" ]] \
|
||||
|| fail "J10: post-assembly PROPFIND expected 404, got $STATUS"
|
||||
pass "J10: session purged after assembly (PROPFIND 404)"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# J9 — DELETE on a FRESH session (abort path) → 204
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
echo " J9: DELETE on a fresh (un-assembled) session → 204"
|
||||
ABORT_SESSION="j-abort-$(date +%s)-$$"
|
||||
ABORT_BASE="$NC_UPLOAD_BASE/$ABORT_SESSION"
|
||||
|
||||
STATUS=$(nc_curl -o /dev/null -w "%{http_code}" -X MKCOL "$ABORT_BASE")
|
||||
[[ "$STATUS" == "201" ]] \
|
||||
|| fail "J9 setup: MKCOL expected 201, got $STATUS"
|
||||
STATUS=$(nc_curl -o /dev/null -w "%{http_code}" -X PUT \
|
||||
--data-binary "@$CHUNK1_PATH" \
|
||||
"$ABORT_BASE/00000001")
|
||||
[[ "$STATUS" == "201" ]] \
|
||||
|| fail "J9 setup: PUT chunk expected 201, got $STATUS"
|
||||
|
||||
STATUS=$(nc_curl -o /dev/null -w "%{http_code}" -X DELETE "$ABORT_BASE")
|
||||
[[ "$STATUS" == "204" ]] \
|
||||
|| fail "J9: DELETE session expected 204, got $STATUS"
|
||||
|
||||
# Confirm it's gone.
|
||||
STATUS=$(nc_curl -o /dev/null -w "%{http_code}" -X PROPFIND -H "Depth: 0" "$ABORT_BASE")
|
||||
[[ "$STATUS" == "404" ]] \
|
||||
|| fail "J9: PROPFIND after DELETE expected 404, got $STATUS"
|
||||
pass "J9: aborted session DELETE → 204 + subsequent PROPFIND 404"
|
||||
|
||||
# ── Cleanup ──────────────────────────────────────────────────────────────────
|
||||
|
||||
echo " cleanup"
|
||||
nc_curl -o /dev/null -X DELETE "$NC_FILES_BASE/j-assembled.bin" || true
|
||||
api_empty_trash || true
|
||||
pass "cleanup done"
|
||||
|
||||
# ── summary ───────────────────────────────────────────────────────────────────
|
||||
|
||||
echo
|
||||
echo "Results: $PASS passed, $FAIL failed."
|
||||
[[ "$FAIL" -eq 0 ]] && echo "All tests passed." || exit 1
|
||||
Executable
+191
@@ -0,0 +1,191 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================
|
||||
# OxiCloud — Baseline: NC cross-user isolation (security)
|
||||
# =============================================================
|
||||
# Group O from BASELINE_TESTS_NC_WEBDAV.md (4 scenarios).
|
||||
#
|
||||
# Security baseline. The NC handlers must enforce the
|
||||
# (username, app-password) auth identity against every URL
|
||||
# path's `{user}` segment — alice authenticated via her app
|
||||
# password must NEVER read, write, or even enumerate files
|
||||
# belonging to bob, regardless of clever URL crafting.
|
||||
#
|
||||
# Depends on `tests/api/nc_second_user_setup.hurl` having
|
||||
# created the bob fixture earlier in run.sh.
|
||||
#
|
||||
# Coverage:
|
||||
# O1 — PROPFIND bob's home folder while auth'd as alice → 403
|
||||
# O2 — Path-traversal attempt
|
||||
# (`/dav/files/alice/../bob/...`) → 400
|
||||
# O3 — MOVE alice's file → bob's home → 403 / 4xx
|
||||
# O4 — Alice's PROPFIND of her own home returns ONLY her
|
||||
# files (no bob files leak across)
|
||||
#
|
||||
# Notes on phrasing of pass conditions:
|
||||
# * For O1 / O3 the "rejection" status code may legitimately
|
||||
# be 403 (URL/auth user mismatch — the cross-check in the
|
||||
# middleware) or 401 (Basic Auth challenge). Both are
|
||||
# acceptable; what matters is that the request does NOT
|
||||
# succeed.
|
||||
# * For O2 the path-traversal rejection is asserted at the
|
||||
# `reject_path_traversal` helper in the dispatcher; if
|
||||
# that returns 400, good. If somehow it sneaks through to
|
||||
# a real lookup, we'd see a 404, which we treat as a fail
|
||||
# because it implies the dispatcher accepted the traversal.
|
||||
# =============================================================
|
||||
|
||||
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 cross-user isolation (Group O baseline) ==="
|
||||
echo
|
||||
|
||||
oxicloud_login # logs in as admin (= "alice" in the scenario phrasing)
|
||||
mint_app_password # admin's app password — referenced as alice's
|
||||
ALICE_APP_PASS="$APP_PASS"
|
||||
ALICE_USERNAME="$username"
|
||||
resolve_home_folder_id
|
||||
wipe_home_folder
|
||||
|
||||
# ── Register bob + mint his app password ─────────────────────────────────────
|
||||
# tests/webdav/run.sh spawns its own postgres + server, so the bob fixture
|
||||
# created by tests/api/nc_second_user_setup.hurl in the api suite is NOT
|
||||
# visible here. Register him inline. The endpoint is anti-enumeration mode
|
||||
# (uniform 200 on success or "already exists"); the real existence check is
|
||||
# the login below — if it returns a JWT, the account is usable.
|
||||
|
||||
BOB_USERNAME="bob"
|
||||
BOB_LOGIN_PW="BobPassword1!"
|
||||
BOB_EMAIL="bob@example.com"
|
||||
|
||||
curl -s -X POST -H "Content-Type: application/json" \
|
||||
-d "{\"username\":\"$BOB_USERNAME\",\"email\":\"$BOB_EMAIL\",\"password\":\"$BOB_LOGIN_PW\"}" \
|
||||
"$base_url/api/auth/register" > /dev/null
|
||||
|
||||
BOB_LOGIN_RESP=$(curl -s -X POST -H "Content-Type: application/json" \
|
||||
-d "{\"username\":\"$BOB_USERNAME\",\"password\":\"$BOB_LOGIN_PW\"}" \
|
||||
"$base_url/api/auth/login")
|
||||
BOB_JWT=$(jq -r '.access_token // empty' <<< "$BOB_LOGIN_RESP" 2>/dev/null || echo "")
|
||||
[[ -n "$BOB_JWT" ]] \
|
||||
|| fail "preflight: bob login failed after inline registration. response=$BOB_LOGIN_RESP"
|
||||
|
||||
BOB_APP_RESP=$(curl -s -X POST \
|
||||
-H "Authorization: Bearer $BOB_JWT" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"label":"nc_cross_user_isolation test (bob)"}' \
|
||||
"$base_url/api/auth/app-passwords")
|
||||
BOB_APP_PASS=$(jq -r '.password // empty' <<< "$BOB_APP_RESP")
|
||||
BOB_APP_PASS_ID=$(jq -r '.id // empty' <<< "$BOB_APP_RESP")
|
||||
[[ -n "$BOB_APP_PASS" ]] \
|
||||
|| fail "preflight: could not mint bob app password (response: $BOB_APP_RESP)"
|
||||
|
||||
# Seed each user's home folder with a probe file so O3/O4 have
|
||||
# something to ask about. Alice's seed goes via REST (`api_upload_file`
|
||||
# already targets her home). Bob's seed goes via NC PUT under his
|
||||
# Basic-Auth identity, since we want it owned by bob.
|
||||
ALICE_FIXTURE_DIR=$(mktemp -d)
|
||||
echo "alice's secret" > "$ALICE_FIXTURE_DIR/alice-secret.txt"
|
||||
api_upload_file "$ALICE_FIXTURE_DIR/alice-secret.txt" "$HOME_FOLDER_ID"
|
||||
|
||||
curl -s -u "$BOB_USERNAME:$BOB_APP_PASS" -X PUT \
|
||||
-H "Content-Type: text/plain" \
|
||||
--data-binary 'bob secret' \
|
||||
"$base_url/remote.php/dav/files/$BOB_USERNAME/bob-secret.txt" > /dev/null
|
||||
|
||||
trap 'rm -rf "$ALICE_FIXTURE_DIR"; \
|
||||
curl -s -X DELETE -H "Authorization: Bearer $BOB_JWT" \
|
||||
"$base_url/api/auth/app-passwords/$BOB_APP_PASS_ID" >/dev/null 2>&1 || true; \
|
||||
wipe_home_folder 2>/dev/null || true' EXIT
|
||||
|
||||
NC_FILES_ALICE="$base_url/remote.php/dav/files/$ALICE_USERNAME"
|
||||
NC_FILES_BOB="$base_url/remote.php/dav/files/$BOB_USERNAME"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# O1 — PROPFIND bob's home while auth'd as alice → 403
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
echo " O1: alice PROPFINDs /dav/files/bob/ → 403"
|
||||
STATUS=$(curl -s -o /dev/null -w "%{http_code}" -u "$ALICE_USERNAME:$ALICE_APP_PASS" \
|
||||
-X PROPFIND -H "Depth: 0" "$NC_FILES_BOB/")
|
||||
case "$STATUS" in
|
||||
403|401)
|
||||
pass "O1: cross-user PROPFIND rejected ($STATUS)"
|
||||
;;
|
||||
*)
|
||||
fail "O1: cross-user PROPFIND should be 403/401, got $STATUS — alice may be reading bob's home!"
|
||||
;;
|
||||
esac
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# O2 — Path traversal in URL → 400
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
echo " O2: path traversal /dav/files/$ALICE_USERNAME/../$BOB_USERNAME/bob-secret.txt → 400"
|
||||
# Send the traversal in raw form (curl --path-as-is keeps `..` instead
|
||||
# of letting curl normalise the URL client-side).
|
||||
STATUS=$(curl -s --path-as-is -o /dev/null -w "%{http_code}" \
|
||||
-u "$ALICE_USERNAME:$ALICE_APP_PASS" \
|
||||
-X PROPFIND -H "Depth: 0" \
|
||||
"$NC_FILES_ALICE/../$BOB_USERNAME/bob-secret.txt")
|
||||
case "$STATUS" in
|
||||
400|403)
|
||||
pass "O2: path traversal rejected ($STATUS)"
|
||||
;;
|
||||
*)
|
||||
fail "O2: path-traversal expected 400/403, got $STATUS"
|
||||
;;
|
||||
esac
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# O3 — MOVE alice's file → bob's home → reject
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
echo " O3: alice MOVEs alice-secret.txt → bob's home → rejected"
|
||||
STATUS=$(curl -s -o /dev/null -w "%{http_code}" -u "$ALICE_USERNAME:$ALICE_APP_PASS" \
|
||||
-X MOVE \
|
||||
-H "Destination: $NC_FILES_BOB/alice-secret-stolen.txt" \
|
||||
"$NC_FILES_ALICE/alice-secret.txt")
|
||||
case "$STATUS" in
|
||||
403|401|400)
|
||||
pass "O3: cross-user MOVE rejected ($STATUS)"
|
||||
;;
|
||||
201|204)
|
||||
# Confirm the destination actually landed in bob's home before
|
||||
# we call this a security failure — the request might have
|
||||
# legitimately failed silently elsewhere.
|
||||
DST_STATUS=$(curl -s -o /dev/null -w "%{http_code}" -u "$BOB_USERNAME:$BOB_APP_PASS" \
|
||||
-X PROPFIND -H "Depth: 0" "$NC_FILES_BOB/alice-secret-stolen.txt")
|
||||
if [[ "$DST_STATUS" == "207" ]]; then
|
||||
fail "O3: SECURITY REGRESSION — alice's file was successfully moved into bob's home (status $STATUS, dest PROPFIND $DST_STATUS)"
|
||||
else
|
||||
pass "O3: MOVE returned $STATUS but destination is NOT in bob's home — effectively rejected"
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
fail "O3: unexpected status $STATUS — cross-user MOVE should be rejected"
|
||||
;;
|
||||
esac
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# O4 — Alice's home PROPFIND must contain alice-secret.txt,
|
||||
# must NOT contain bob-secret.txt
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
echo " O4: alice's home PROPFIND contains alice-secret.txt only (not bob-secret)"
|
||||
BODY=$(curl -s -u "$ALICE_USERNAME:$ALICE_APP_PASS" \
|
||||
-X PROPFIND -H "Depth: 1" "$NC_FILES_ALICE/")
|
||||
grep -q 'alice-secret.txt' <<< "$BODY" \
|
||||
|| fail "O4: alice-secret.txt missing from alice's home PROPFIND"
|
||||
if grep -q 'bob-secret.txt' <<< "$BODY"; then
|
||||
fail "O4: SECURITY REGRESSION — bob-secret.txt leaked into alice's home PROPFIND"
|
||||
fi
|
||||
pass "O4: alice's home contains only her own file; no bob leakage"
|
||||
|
||||
# ── summary ───────────────────────────────────────────────────────────────────
|
||||
|
||||
echo
|
||||
echo "Results: $PASS passed, $FAIL failed."
|
||||
[[ "$FAIL" -eq 0 ]] && echo "All tests passed." || exit 1
|
||||
Executable
+214
@@ -0,0 +1,214 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================
|
||||
# OxiCloud — Baseline: NC PROPPATCH favorites + REPORT
|
||||
# =============================================================
|
||||
# Groups H + I from BASELINE_TESTS_NC_WEBDAV.md (7 scenarios).
|
||||
# Combined because they exercise the same two-step round-trip:
|
||||
# set a favorite via PROPPATCH (H), confirm it surfaces in
|
||||
# the REPORT favorites filter (I).
|
||||
#
|
||||
# Coverage:
|
||||
# H1 — PROPPATCH oc:favorite=1 on a file → 207
|
||||
# H2 — same file appears in REPORT favorites filter
|
||||
# H3 — PROPPATCH oc:favorite=0 → file removed from favorites
|
||||
# I1 — REPORT favorites on empty home → empty multistatus
|
||||
# I2 — REPORT favorites with 3 marked files → 3 entries
|
||||
# I3 — REPORT searchrequest LIKE %foo% returns matching files
|
||||
# I4 — REPORT searchrequest with nresults caps the result count
|
||||
#
|
||||
# xq is used wherever counting / per-response extraction would
|
||||
# be brittle with awk (namespaced multistatus + filter-rules
|
||||
# body).
|
||||
# =============================================================
|
||||
|
||||
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 PROPPATCH favorites + REPORT (Groups H + I baseline) ==="
|
||||
echo
|
||||
|
||||
command -v xq >/dev/null 2>&1 || fail "preflight: xq required"
|
||||
|
||||
oxicloud_login
|
||||
mint_app_password
|
||||
resolve_home_folder_id
|
||||
wipe_home_folder
|
||||
|
||||
NC_FILES_BASE="$base_url/remote.php/dav/files/$username"
|
||||
FIXTURE_DIR=$(mktemp -d)
|
||||
trap 'rm -rf "$FIXTURE_DIR"; wipe_home_folder 2>/dev/null || true' EXIT
|
||||
|
||||
# ── Fixture setup via REST ───────────────────────────────────────────────────
|
||||
# Five files for I3 / I4 search assertions: foo.txt, foobar.txt, bar.txt,
|
||||
# foobaz.txt, qux.txt — three contain "foo", two don't.
|
||||
for name in foo.txt foobar.txt bar.txt foobaz.txt qux.txt; do
|
||||
echo "content of $name" > "$FIXTURE_DIR/$name"
|
||||
api_upload_file "$FIXTURE_DIR/$name" "$HOME_FOLDER_ID"
|
||||
done
|
||||
|
||||
# Three of the five will be favorited for H/I scenarios. Capture their
|
||||
# REST ids so we can sanity-check the favorite state via the API later.
|
||||
# foo.txt + foobar.txt + qux.txt = 3 favorites.
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# I1 — REPORT favorites on empty-favorites state → empty
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
echo " I1: REPORT favorites filter (none marked yet) → empty multistatus"
|
||||
FILTER_BODY='<?xml version="1.0" encoding="utf-8"?>
|
||||
<oc:filter-files xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
|
||||
<oc:filter-rules>
|
||||
<oc:favorite>1</oc:favorite>
|
||||
</oc:filter-rules>
|
||||
</oc:filter-files>'
|
||||
|
||||
BODY=$(nc_curl -X REPORT -H "Content-Type: application/xml" \
|
||||
--data "$FILTER_BODY" "$NC_FILES_BASE/")
|
||||
N=$(xq -x 'count(//*[local-name()="response"])' <<< "$BODY" | tr -d '\r\n ')
|
||||
[[ "$N" == "0" ]] \
|
||||
|| fail "I1: REPORT favorites (empty) expected 0 responses, got '$N'"
|
||||
pass "I1: empty-favorites REPORT returns 0 <d:response> entries"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# H1 — PROPPATCH oc:favorite=1 on foo.txt → 207
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PROPPATCH_FAV1='<?xml version="1.0" encoding="utf-8"?>
|
||||
<d:propertyupdate xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
|
||||
<d:set><d:prop><oc:favorite>1</oc:favorite></d:prop></d:set>
|
||||
</d:propertyupdate>'
|
||||
|
||||
PROPPATCH_FAV0='<?xml version="1.0" encoding="utf-8"?>
|
||||
<d:propertyupdate xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
|
||||
<d:set><d:prop><oc:favorite>0</oc:favorite></d:prop></d:set>
|
||||
</d:propertyupdate>'
|
||||
|
||||
echo " H1: PROPPATCH oc:favorite=1 on /foo.txt → 207"
|
||||
HEADERS=$(nc_curl -D - -o /dev/null -X PROPPATCH \
|
||||
-H "Content-Type: application/xml" \
|
||||
--data "$PROPPATCH_FAV1" \
|
||||
"$NC_FILES_BASE/foo.txt")
|
||||
STATUS=$(awk 'NR==1{print $2}' <<< "$HEADERS" | tr -d '\r')
|
||||
[[ "$STATUS" == "207" ]] \
|
||||
|| fail "H1: PROPPATCH expected 207, got $STATUS"
|
||||
pass "H1: PROPPATCH oc:favorite=1 → 207"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# H2 — foo.txt now appears in REPORT favorites filter
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
echo " H2: REPORT favorites filter now includes /foo.txt"
|
||||
BODY=$(nc_curl -X REPORT -H "Content-Type: application/xml" \
|
||||
--data "$FILTER_BODY" "$NC_FILES_BASE/")
|
||||
N=$(xq -x 'count(//*[local-name()="response"])' <<< "$BODY" | tr -d '\r\n ')
|
||||
[[ "$N" == "1" ]] \
|
||||
|| fail "H2: expected 1 favorited entry, got '$N'"
|
||||
# xq doesn't accept function-returning XPaths (e.g. boolean(...))
|
||||
# at top level — it expects node selection. Plain grep is fine for
|
||||
# substring-presence checks against an already-fetched body.
|
||||
grep -q '/foo.txt' <<< "$BODY" \
|
||||
|| fail "H2: favorites response does not contain /foo.txt"
|
||||
pass "H2: REPORT favorites contains /foo.txt"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# H3 — PROPPATCH oc:favorite=0 removes the favorite
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
echo " H3: PROPPATCH oc:favorite=0 removes /foo.txt from favorites"
|
||||
STATUS=$(nc_curl -o /dev/null -w "%{http_code}" -X PROPPATCH \
|
||||
-H "Content-Type: application/xml" \
|
||||
--data "$PROPPATCH_FAV0" \
|
||||
"$NC_FILES_BASE/foo.txt")
|
||||
[[ "$STATUS" == "207" ]] \
|
||||
|| fail "H3: PROPPATCH unfavorite expected 207, got $STATUS"
|
||||
BODY=$(nc_curl -X REPORT -H "Content-Type: application/xml" \
|
||||
--data "$FILTER_BODY" "$NC_FILES_BASE/")
|
||||
N=$(xq -x 'count(//*[local-name()="response"])' <<< "$BODY" | tr -d '\r\n ')
|
||||
[[ "$N" == "0" ]] \
|
||||
|| fail "H3: after unfavorite expected 0 entries, got '$N'"
|
||||
pass "H3: PROPPATCH oc:favorite=0 → file removed from favorites"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# I2 — REPORT favorites with 3 marked files → 3 entries
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
echo " I2: REPORT favorites with 3 marked files → 3 responses"
|
||||
for fname in foo.txt foobar.txt qux.txt; do
|
||||
STATUS=$(nc_curl -o /dev/null -w "%{http_code}" -X PROPPATCH \
|
||||
-H "Content-Type: application/xml" \
|
||||
--data "$PROPPATCH_FAV1" \
|
||||
"$NC_FILES_BASE/$fname")
|
||||
[[ "$STATUS" == "207" ]] \
|
||||
|| fail "I2 setup: PROPPATCH on $fname expected 207, got $STATUS"
|
||||
done
|
||||
BODY=$(nc_curl -X REPORT -H "Content-Type: application/xml" \
|
||||
--data "$FILTER_BODY" "$NC_FILES_BASE/")
|
||||
N=$(xq -x 'count(//*[local-name()="response"])' <<< "$BODY" | tr -d '\r\n ')
|
||||
[[ "$N" == "3" ]] \
|
||||
|| fail "I2: expected 3 favorited entries, got '$N'"
|
||||
pass "I2: 3 favorited files appear in REPORT response"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# I3 — REPORT searchrequest LIKE %foo% returns matching files
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
echo " I3: REPORT searchrequest LIKE %foo% → foo.txt + foobar.txt + foobaz.txt"
|
||||
SEARCH_BODY='<?xml version="1.0" encoding="utf-8"?>
|
||||
<d:searchrequest xmlns:d="DAV:">
|
||||
<d:basicsearch>
|
||||
<d:select><d:prop><d:displayname/></d:prop></d:select>
|
||||
<d:from><d:scope><d:href>/remote.php/dav/files/'"$username"'/</d:href></d:scope></d:from>
|
||||
<d:where>
|
||||
<d:like>
|
||||
<d:prop><d:displayname/></d:prop>
|
||||
<d:literal>%foo%</d:literal>
|
||||
</d:like>
|
||||
</d:where>
|
||||
</d:basicsearch>
|
||||
</d:searchrequest>'
|
||||
|
||||
BODY=$(nc_curl -X REPORT -H "Content-Type: application/xml" \
|
||||
--data "$SEARCH_BODY" "$NC_FILES_BASE/")
|
||||
N=$(xq -x 'count(//*[local-name()="response"])' <<< "$BODY" | tr -d '\r\n ')
|
||||
[[ "$N" == "3" ]] \
|
||||
|| fail "I3: search expected 3 'foo'-containing matches, got '$N'"
|
||||
# Confirm bar.txt is NOT in the search results. Plain grep — same
|
||||
# reason as H2 above (xq doesn't take function-returning XPaths).
|
||||
# Use `grep -v` style: assert that bar.txt does NOT appear.
|
||||
if grep -q '/bar.txt' <<< "$BODY"; then
|
||||
fail "I3: bar.txt erroneously matched 'foo' search"
|
||||
fi
|
||||
pass "I3: search returns exactly the 'foo'-containing files (bar.txt + qux.txt excluded)"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# I4 — REPORT searchrequest with nresults cap
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
echo " I4: REPORT searchrequest with <nresults>2</nresults> caps at 2"
|
||||
SEARCH_BODY_LIMITED='<?xml version="1.0" encoding="utf-8"?>
|
||||
<d:searchrequest xmlns:d="DAV:">
|
||||
<d:basicsearch>
|
||||
<d:select><d:prop><d:displayname/></d:prop></d:select>
|
||||
<d:from><d:scope><d:href>/remote.php/dav/files/'"$username"'/</d:href></d:scope></d:from>
|
||||
<d:where>
|
||||
<d:like>
|
||||
<d:prop><d:displayname/></d:prop>
|
||||
<d:literal>%foo%</d:literal>
|
||||
</d:like>
|
||||
</d:where>
|
||||
<d:nresults>2</d:nresults>
|
||||
</d:basicsearch>
|
||||
</d:searchrequest>'
|
||||
|
||||
BODY=$(nc_curl -X REPORT -H "Content-Type: application/xml" \
|
||||
--data "$SEARCH_BODY_LIMITED" "$NC_FILES_BASE/")
|
||||
N=$(xq -x 'count(//*[local-name()="response"])' <<< "$BODY" | tr -d '\r\n ')
|
||||
[[ "$N" == "2" ]] \
|
||||
|| fail "I4: nresults=2 should cap result count at 2, got '$N'"
|
||||
pass "I4: <nresults>2</nresults> correctly caps the response count"
|
||||
|
||||
# ── summary ───────────────────────────────────────────────────────────────────
|
||||
|
||||
echo
|
||||
echo "Results: $PASS passed, $FAIL failed."
|
||||
[[ "$FAIL" -eq 0 ]] && echo "All tests passed." || exit 1
|
||||
Executable
+195
@@ -0,0 +1,195 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================
|
||||
# OxiCloud — Baseline: NC WebDAV GET / HEAD / Range
|
||||
# =============================================================
|
||||
# Group E from BASELINE_TESTS_NC_WEBDAV.md (6 scenarios).
|
||||
#
|
||||
# Sequence:
|
||||
# E1 GET a small file — 200 + ETag + Last-Modified + Content-Type
|
||||
# E2 HEAD same file — same headers, empty body
|
||||
# E3 GET non-existent — 404
|
||||
# E4 GET a collection — pin current behavior
|
||||
# E5 GET a 1 MB file with Range — 206 + Content-Range
|
||||
# E6 GET with If-None-Match: <current-etag> → 304
|
||||
#
|
||||
# Catches the file_id→blob_hash cache stale-content regression
|
||||
# (commit f4ce4092): if the cache returned an old blob_hash,
|
||||
# E2/E3 reads of an overwritten file would serve stale content.
|
||||
# That specific overwrite scenario is covered by Group F (PUT);
|
||||
# this group establishes the read-side baseline.
|
||||
# =============================================================
|
||||
|
||||
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 GET / HEAD / Range (Group E baseline) ==="
|
||||
echo
|
||||
|
||||
oxicloud_login
|
||||
mint_app_password
|
||||
resolve_home_folder_id
|
||||
wipe_home_folder # defensive against cross-test contamination
|
||||
|
||||
NC_FILES_BASE="$base_url/remote.php/dav/files/$username"
|
||||
|
||||
GET_FIXTURE_DIR=$(mktemp -d)
|
||||
trap 'rm -rf "$GET_FIXTURE_DIR"' EXIT
|
||||
|
||||
# Small fixture — exact known content for E1 / E2 / E6.
|
||||
SMALL_CONTENT="hello from group E baseline"
|
||||
SMALL_PATH="$GET_FIXTURE_DIR/small.txt"
|
||||
printf '%s' "$SMALL_CONTENT" > "$SMALL_PATH"
|
||||
SMALL_LEN=$(wc -c < "$SMALL_PATH" | tr -d ' ')
|
||||
|
||||
# 1 MB random binary — E5 Range request.
|
||||
LARGE_PATH="$GET_FIXTURE_DIR/medium-1mb.bin"
|
||||
dd if=/dev/urandom of="$LARGE_PATH" bs=1024 count=1024 status=none
|
||||
LARGE_LEN=$(wc -c < "$LARGE_PATH" | tr -d ' ')
|
||||
|
||||
# Upload both via REST so this test only exercises the GET surface.
|
||||
api_upload_file "$SMALL_PATH" "$HOME_FOLDER_ID"
|
||||
SMALL_FILE_ID="$LAST_FILE_ID"
|
||||
|
||||
api_upload_file "$LARGE_PATH" "$HOME_FOLDER_ID"
|
||||
LARGE_FILE_ID="$LAST_FILE_ID"
|
||||
|
||||
SMALL_URL="$NC_FILES_BASE/$(basename "$SMALL_PATH")"
|
||||
LARGE_URL="$NC_FILES_BASE/$(basename "$LARGE_PATH")"
|
||||
|
||||
# A subfolder for E4 (GET on a collection).
|
||||
api_create_folder "get-collection-probe" "$HOME_FOLDER_ID"
|
||||
SUBFOLDER_ID="$LAST_FOLDER_ID"
|
||||
SUBFOLDER_URL="$NC_FILES_BASE/get-collection-probe/"
|
||||
|
||||
trap 'rm -rf "$GET_FIXTURE_DIR"; \
|
||||
api_delete_file "$SMALL_FILE_ID" 2>/dev/null || true; \
|
||||
api_delete_file "$LARGE_FILE_ID" 2>/dev/null || true; \
|
||||
api_delete_folder "$SUBFOLDER_ID" 2>/dev/null || true; \
|
||||
api_empty_trash 2>/dev/null || true' EXIT
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# E1 — GET small file → 200 + headers + body
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
echo " E1: GET small file"
|
||||
RESPONSE=$(nc_curl -i "$SMALL_URL")
|
||||
STATUS=$(awk 'NR==1{print $2}' <<< "$RESPONSE" | tr -d '\r')
|
||||
[[ "$STATUS" == "200" ]] \
|
||||
|| fail "E1: expected 200, got $STATUS"
|
||||
# Headers section ends at the first blank line.
|
||||
HEADERS=$(awk 'BEGIN{p=1} /^\r?$/{p=0} p' <<< "$RESPONSE" | tr -d '\r')
|
||||
BODY=$(awk 'BEGIN{p=0} p; /^\r?$/{p=1}' <<< "$RESPONSE" | tr -d '\r')
|
||||
grep -qi '^content-type:' <<< "$HEADERS" \
|
||||
|| fail "E1: missing Content-Type header"
|
||||
grep -qi '^content-length:' <<< "$HEADERS" \
|
||||
|| fail "E1: missing Content-Length header"
|
||||
grep -qi '^etag:' <<< "$HEADERS" \
|
||||
|| fail "E1: missing ETag header"
|
||||
grep -qi '^last-modified:' <<< "$HEADERS" \
|
||||
|| fail "E1: missing Last-Modified header"
|
||||
CLEN=$(grep -i '^content-length:' <<< "$HEADERS" | awk '{print $2}' | tr -d '\r')
|
||||
[[ "$CLEN" == "$SMALL_LEN" ]] \
|
||||
|| fail "E1: Content-Length mismatch — expected $SMALL_LEN, got $CLEN"
|
||||
# Body equals the uploaded bytes.
|
||||
ACTUAL_BODY=$(nc_curl "$SMALL_URL")
|
||||
[[ "$ACTUAL_BODY" == "$SMALL_CONTENT" ]] \
|
||||
|| fail "E1: body mismatch — got '$ACTUAL_BODY', expected '$SMALL_CONTENT'"
|
||||
# Capture the ETag for E6.
|
||||
E1_ETAG=$(grep -i '^etag:' <<< "$HEADERS" | awk '{print $2}' | tr -d '\r')
|
||||
pass "E1: GET small file — 200 + Content-Type/Length/ETag/Last-Modified, body matches"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# E2 — HEAD same file → same headers, empty body
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
echo " E2: HEAD small file"
|
||||
RESPONSE=$(nc_curl -I "$SMALL_URL")
|
||||
STATUS=$(awk 'NR==1{print $2}' <<< "$RESPONSE" | tr -d '\r')
|
||||
[[ "$STATUS" == "200" ]] \
|
||||
|| fail "E2: expected 200, got $STATUS"
|
||||
grep -qi '^content-length:' <<< "$RESPONSE" \
|
||||
|| fail "E2: missing Content-Length on HEAD"
|
||||
grep -qi '^etag:' <<< "$RESPONSE" \
|
||||
|| fail "E2: missing ETag on HEAD"
|
||||
# `curl -I` body should be empty.
|
||||
BODY_SIZE=$(nc_curl -I "$SMALL_URL" -w '%{size_download}' -o /dev/null)
|
||||
[[ "$BODY_SIZE" == "0" ]] \
|
||||
|| fail "E2: HEAD body must be empty, got $BODY_SIZE bytes"
|
||||
pass "E2: HEAD small file — same headers, empty body"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# E3 — GET non-existent file → 404
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
echo " E3: GET non-existent file → 404"
|
||||
STATUS=$(nc_curl -o /dev/null -w "%{http_code}" \
|
||||
"$NC_FILES_BASE/this-does-not-exist-$(date +%s).txt")
|
||||
[[ "$STATUS" == "404" ]] \
|
||||
|| fail "E3: expected 404, got $STATUS"
|
||||
pass "E3: non-existent → 404"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# E4 — GET on a collection
|
||||
# Per BASELINE doc §7 "Open questions", pin whatever the
|
||||
# current behavior is (200 vs 404). Both are acceptable
|
||||
# values for NC; the regression we care about is "did the
|
||||
# shape change". The assertion below records the current
|
||||
# behavior so any future drift is caught.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
echo " E4: GET on a collection — pin current behavior"
|
||||
STATUS=$(nc_curl -o /dev/null -w "%{http_code}" "$SUBFOLDER_URL")
|
||||
case "$STATUS" in
|
||||
200|404)
|
||||
pass "E4: GET on collection returns $STATUS (pinned)"
|
||||
;;
|
||||
*)
|
||||
fail "E4: unexpected status $STATUS (expected 200 or 404)"
|
||||
;;
|
||||
esac
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# E5 — Range request on 1 MB file
|
||||
#
|
||||
# Must use GET (not HEAD) — `handle_head` in the NC surface
|
||||
# doesn't receive the request headers and never invokes the
|
||||
# Range-response path. Range against HEAD silently returns
|
||||
# 200 with all headers, which masks the Range support and
|
||||
# would falsely pass an E5 written with `curl -I`. Use
|
||||
# `-D - -o /dev/null` instead: defaults to GET, dumps the
|
||||
# response headers to stdout, throws the body away — gives
|
||||
# us the status + Content-Range without downloading the slice.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
echo " E5: GET with Range: bytes=0-1023 on 1 MB file → 206"
|
||||
RESPONSE_HEADERS=$(nc_curl -D - -o /dev/null -H "Range: bytes=0-1023" "$LARGE_URL")
|
||||
STATUS=$(awk 'NR==1{print $2}' <<< "$RESPONSE_HEADERS" | tr -d '\r')
|
||||
[[ "$STATUS" == "206" ]] \
|
||||
|| fail "E5: expected 206, got $STATUS"
|
||||
grep -qi "^content-range:.*0-1023/$LARGE_LEN" <<< "$RESPONSE_HEADERS" \
|
||||
|| fail "E5: missing or wrong Content-Range header"
|
||||
# Actually fetch the slice and verify byte count.
|
||||
BODY_SIZE=$(nc_curl -H "Range: bytes=0-1023" "$LARGE_URL" | wc -c | tr -d ' ')
|
||||
[[ "$BODY_SIZE" == "1024" ]] \
|
||||
|| fail "E5: Range body size expected 1024, got $BODY_SIZE"
|
||||
pass "E5: Range bytes=0-1023 — 206 + Content-Range correct + 1024 bytes"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# E6 — GET with If-None-Match matching the stored ETag → 304
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
echo " E6: GET with If-None-Match matching ETag → 304"
|
||||
[[ -n "$E1_ETAG" ]] \
|
||||
|| fail "E6: precondition — E1 should have captured an ETag"
|
||||
STATUS=$(nc_curl -o /dev/null -w "%{http_code}" \
|
||||
-H "If-None-Match: $E1_ETAG" "$SMALL_URL")
|
||||
[[ "$STATUS" == "304" ]] \
|
||||
|| fail "E6: If-None-Match matching expected 304, got $STATUS (etag was $E1_ETAG)"
|
||||
pass "E6: If-None-Match matches → 304"
|
||||
|
||||
# ── summary ───────────────────────────────────────────────────────────────────
|
||||
|
||||
echo
|
||||
echo "Results: $PASS passed, $FAIL failed."
|
||||
[[ "$FAIL" -eq 0 ]] && echo "All tests passed." || exit 1
|
||||
+443
@@ -0,0 +1,443 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================
|
||||
# OxiCloud — Baseline: NC WebDAV MOVE / COPY / DELETE + Trashbin
|
||||
# =============================================================
|
||||
# Groups G + K from BASELINE_TESTS_NC_WEBDAV.md (14 scenarios).
|
||||
# Combined into one file because G8 (DELETE) feeds K1-K4
|
||||
# (trashbin lifecycle) — same fixtures, shared lifecycle.
|
||||
#
|
||||
# Pinned behaviour notes:
|
||||
# G4 / G5 — `Overwrite` request header is NOT honoured by the
|
||||
# NC MOVE handler today. Both `Overwrite: F` and
|
||||
# `Overwrite: T` succeed identically. Asserted as
|
||||
# current-behaviour pins so any future "we now
|
||||
# honour Overwrite" change is caught.
|
||||
# G7 — COPY method is not dispatched by handle_nc_webdav,
|
||||
# so it falls through to METHOD_NOT_ALLOWED (405).
|
||||
# Pinned; a future COPY implementation will flip
|
||||
# this to 201/204 and the test will trip.
|
||||
# =============================================================
|
||||
|
||||
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 MOVE / COPY / DELETE + Trashbin (Groups G + K baseline) ==="
|
||||
echo
|
||||
|
||||
oxicloud_login
|
||||
mint_app_password
|
||||
resolve_home_folder_id
|
||||
wipe_home_folder # defensive against cross-test contamination
|
||||
|
||||
NC_FILES_BASE="$base_url/remote.php/dav/files/$username"
|
||||
NC_TRASH_BASE="$base_url/remote.php/dav/trashbin/$username/trash"
|
||||
|
||||
FIXTURE_DIR=$(mktemp -d)
|
||||
trap 'rm -rf "$FIXTURE_DIR"; \
|
||||
nc_curl -o /dev/null -X DELETE "$NC_FILES_BASE/g-leftover/" 2>/dev/null || true; \
|
||||
nc_curl -o /dev/null -X DELETE "$NC_FILES_BASE/g7-source.txt" 2>/dev/null || true; \
|
||||
api_empty_trash 2>/dev/null || true' EXIT
|
||||
|
||||
# ── Helper: PUT a small file via NC for fixture setup ────────────────────────
|
||||
put_nc_file() {
|
||||
local name="$1" content="$2"
|
||||
printf '%s' "$content" | nc_curl -X PUT \
|
||||
-H "Content-Type: text/plain" \
|
||||
--data-binary @- \
|
||||
"$NC_FILES_BASE/$name" > /dev/null
|
||||
}
|
||||
|
||||
# ── Helper: PROPFIND status for a path (for "exists / 404" assertions) ───────
|
||||
nc_status_propfind_depth0() {
|
||||
nc_curl -o /dev/null -w "%{http_code}" -X PROPFIND -H "Depth: 0" "$1"
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# G1 — MOVE file to new name (rename)
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
echo " G1: MOVE rename a.txt → b.txt"
|
||||
put_nc_file "g1-a.txt" "G1 contents"
|
||||
STATUS=$(nc_curl -o /dev/null -w "%{http_code}" -X MOVE \
|
||||
-H "Destination: $NC_FILES_BASE/g1-b.txt" \
|
||||
"$NC_FILES_BASE/g1-a.txt")
|
||||
[[ "$STATUS" == "201" || "$STATUS" == "204" ]] \
|
||||
|| fail "G1: MOVE rename expected 201/204, got $STATUS"
|
||||
[[ "$(nc_status_propfind_depth0 "$NC_FILES_BASE/g1-b.txt")" == "207" ]] \
|
||||
|| fail "G1: destination g1-b.txt missing after MOVE"
|
||||
[[ "$(nc_status_propfind_depth0 "$NC_FILES_BASE/g1-a.txt")" == "404" ]] \
|
||||
|| fail "G1: source g1-a.txt still present after MOVE"
|
||||
pass "G1: MOVE rename — destination present, source gone"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# G2 — MOVE file to different folder
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
echo " G2: MOVE file into a subfolder"
|
||||
nc_curl -o /dev/null -X MKCOL "$NC_FILES_BASE/g2-folder/" > /dev/null
|
||||
STATUS=$(nc_curl -o /dev/null -w "%{http_code}" -X MOVE \
|
||||
-H "Destination: $NC_FILES_BASE/g2-folder/g1-b.txt" \
|
||||
"$NC_FILES_BASE/g1-b.txt")
|
||||
[[ "$STATUS" == "201" || "$STATUS" == "204" ]] \
|
||||
|| fail "G2: MOVE into subfolder expected 201/204, got $STATUS"
|
||||
[[ "$(nc_status_propfind_depth0 "$NC_FILES_BASE/g2-folder/g1-b.txt")" == "207" ]] \
|
||||
|| fail "G2: g2-folder/g1-b.txt not at new path"
|
||||
[[ "$(nc_status_propfind_depth0 "$NC_FILES_BASE/g1-b.txt")" == "404" ]] \
|
||||
|| fail "G2: source g1-b.txt still present after MOVE to subfolder"
|
||||
pass "G2: MOVE into subfolder — file at destination, gone from source"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# G3 — Destination header with URL-encoded special chars
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
echo " G3: MOVE with URL-encoded destination (space + #)"
|
||||
put_nc_file "g3-src.txt" "G3 contents"
|
||||
# Filename "name with #hash.txt" → URL-encoded.
|
||||
ENCODED_NAME="name%20with%20%23hash.txt"
|
||||
STATUS=$(nc_curl -o /dev/null -w "%{http_code}" -X MOVE \
|
||||
-H "Destination: $NC_FILES_BASE/$ENCODED_NAME" \
|
||||
"$NC_FILES_BASE/g3-src.txt")
|
||||
[[ "$STATUS" == "201" || "$STATUS" == "204" ]] \
|
||||
|| fail "G3: encoded MOVE expected 201/204, got $STATUS"
|
||||
[[ "$(nc_status_propfind_depth0 "$NC_FILES_BASE/$ENCODED_NAME")" == "207" ]] \
|
||||
|| fail "G3: encoded destination not found"
|
||||
pass "G3: URL-encoded destination decoded correctly"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# G4 / G5 — Overwrite header behaviour (pinned: not honoured)
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
echo " G4: MOVE with Overwrite: F to an existing path (pinned: SERVER BUG — leaks 500)"
|
||||
put_nc_file "g4-src.txt" "G4 source"
|
||||
put_nc_file "g4-dest.txt" "G4 destination (should remain)"
|
||||
STATUS=$(nc_curl -o /dev/null -w "%{http_code}" -X MOVE \
|
||||
-H "Destination: $NC_FILES_BASE/g4-dest.txt" \
|
||||
-H "Overwrite: F" \
|
||||
"$NC_FILES_BASE/g4-src.txt")
|
||||
case "$STATUS" in
|
||||
500)
|
||||
# KNOWN BUG: the NC MOVE handler doesn't intercept
|
||||
# `Overwrite: F` and doesn't map the domain-layer
|
||||
# `AlreadyExists` to 412. It tries to rename, the
|
||||
# storage layer 409s "name already taken", and the
|
||||
# handler bubbles that up as 500. NC desktop will
|
||||
# interpret 500 as "server transient error" and
|
||||
# retry, which masks the real conflict.
|
||||
#
|
||||
# The right fix is in `interfaces/nextcloud/webdav_handler.rs::handle_move`:
|
||||
# check `Overwrite: F` BEFORE attempting the rename, return
|
||||
# 412 on collision; OR when Overwrite is omitted/T, delete
|
||||
# the destination first (replace semantics, → 204).
|
||||
pass "G4: Overwrite: F → 500 (KNOWN BUG: should be 412 per RFC 4918 §9.9.4 — pinned)"
|
||||
;;
|
||||
412)
|
||||
fail "G4: server now correctly returns 412 for Overwrite: F. Bug is fixed — update this pin to assert == 412."
|
||||
;;
|
||||
201|204)
|
||||
fail "G4: server now silently overwrites despite Overwrite: F (status $STATUS) — this would be a *different* bug; RFC requires 412."
|
||||
;;
|
||||
*)
|
||||
fail "G4: unexpected status $STATUS"
|
||||
;;
|
||||
esac
|
||||
|
||||
echo " G5: MOVE with Overwrite: T to an existing path (pinned: SERVER BUG — leaks 500)"
|
||||
put_nc_file "g5-src.txt" "G5 source"
|
||||
put_nc_file "g5-dest.txt" "G5 destination (to be replaced)"
|
||||
STATUS=$(nc_curl -o /dev/null -w "%{http_code}" -X MOVE \
|
||||
-H "Destination: $NC_FILES_BASE/g5-dest.txt" \
|
||||
-H "Overwrite: T" \
|
||||
"$NC_FILES_BASE/g5-src.txt")
|
||||
case "$STATUS" in
|
||||
500)
|
||||
# Same root cause as G4: the handler doesn't consider the
|
||||
# `Overwrite` header at all. With `Overwrite: T` it SHOULD
|
||||
# delete the destination first and proceed (→ 204), but
|
||||
# today it bubbles up the storage-layer "Already Exists".
|
||||
pass "G5: Overwrite: T → 500 (KNOWN BUG: should be 204 per RFC 4918 §9.9.4 — pinned)"
|
||||
;;
|
||||
204)
|
||||
fail "G5: server now correctly returns 204 for Overwrite: T. Bug is fixed — update this pin to assert == 204."
|
||||
;;
|
||||
*)
|
||||
fail "G5: unexpected status $STATUS"
|
||||
;;
|
||||
esac
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# G6 — MOVE a folder (subtree)
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
echo " G6: MOVE folder (recursive subtree)"
|
||||
nc_curl -o /dev/null -X MKCOL "$NC_FILES_BASE/g6-tree/" > /dev/null
|
||||
put_nc_file "g6-tree/inside.txt" "G6 inside contents"
|
||||
STATUS=$(nc_curl -o /dev/null -w "%{http_code}" -X MOVE \
|
||||
-H "Destination: $NC_FILES_BASE/g6-tree-moved/" \
|
||||
"$NC_FILES_BASE/g6-tree/")
|
||||
[[ "$STATUS" == "201" || "$STATUS" == "204" ]] \
|
||||
|| fail "G6: folder MOVE expected 201/204, got $STATUS"
|
||||
# Subtree intact at new location.
|
||||
[[ "$(nc_status_propfind_depth0 "$NC_FILES_BASE/g6-tree-moved/inside.txt")" == "207" ]] \
|
||||
|| fail "G6: nested file missing after folder MOVE"
|
||||
[[ "$(nc_status_propfind_depth0 "$NC_FILES_BASE/g6-tree/")" == "404" ]] \
|
||||
|| fail "G6: source folder still present after MOVE"
|
||||
pass "G6: folder MOVE relocates the whole subtree"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# G7 — COPY method (pinned: not implemented → 405)
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
echo " G7: COPY method (pinned: handler not implemented → 405)"
|
||||
put_nc_file "g7-source.txt" "G7 contents"
|
||||
STATUS=$(nc_curl -o /dev/null -w "%{http_code}" -X COPY \
|
||||
-H "Destination: $NC_FILES_BASE/g7-copy.txt" \
|
||||
"$NC_FILES_BASE/g7-source.txt")
|
||||
case "$STATUS" in
|
||||
405)
|
||||
pass "G7: COPY → 405 METHOD_NOT_ALLOWED (handler not implemented) — pinned"
|
||||
;;
|
||||
201|204)
|
||||
fail "G7: COPY now succeeds ($STATUS) — handler was implemented; update pin and add positive assertions."
|
||||
;;
|
||||
*)
|
||||
fail "G7: unexpected status $STATUS"
|
||||
;;
|
||||
esac
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# G8 — DELETE a file → 204 + GET 404 + appears in trash
|
||||
#
|
||||
# This step feeds K1: the deleted item must surface in the
|
||||
# trashbin PROPFIND below.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
echo " G8: DELETE file → 204, GET 404, trashbin lists it"
|
||||
put_nc_file "g8-doomed.txt" "G8 doomed contents"
|
||||
STATUS=$(nc_curl -o /dev/null -w "%{http_code}" -X DELETE "$NC_FILES_BASE/g8-doomed.txt")
|
||||
[[ "$STATUS" == "204" ]] \
|
||||
|| fail "G8: DELETE expected 204, got $STATUS"
|
||||
GET_STATUS=$(nc_curl -o /dev/null -w "%{http_code}" "$NC_FILES_BASE/g8-doomed.txt")
|
||||
[[ "$GET_STATUS" == "404" ]] \
|
||||
|| fail "G8: GET after DELETE expected 404, got $GET_STATUS"
|
||||
pass "G8: DELETE → 204 + GET 404"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# G9 — DELETE a folder (pinned: SERVER BUG — descendants orphan)
|
||||
#
|
||||
# Current behaviour: the NC DELETE handler calls
|
||||
# `trash_svc.move_to_trash(&folder.id, "folder", …)`. That
|
||||
# flips the folder row's `is_trashed=true`, but descendant
|
||||
# files / subfolders are NOT recursively trashed at the row
|
||||
# level. The folder itself becomes invisible (PROPFIND on the
|
||||
# folder URL → 404, correct), but every descendant remains
|
||||
# directly queryable via PROPFIND on its full path. That's
|
||||
# data-integrity weird: clients can still GET/PUT/DELETE the
|
||||
# descendants even though their parent collection is "gone".
|
||||
#
|
||||
# Why this is a bug:
|
||||
# - NC desktop's tree walk PROPFINDs the descendants via the
|
||||
# parent's response; the parent being 404 stops descent and
|
||||
# the orphans never get noticed → eventual drift between
|
||||
# server state and client cache.
|
||||
# - Trash restore expects to recreate the folder + reattach
|
||||
# descendants; with descendants still "live" the restore
|
||||
# path will collide on their names.
|
||||
#
|
||||
# Where the fix needs to live:
|
||||
# `application/services/trash_service.rs::move_to_trash` (or
|
||||
# the folder-write repository it delegates to) — when a
|
||||
# folder is trashed, recursively mark its descendants
|
||||
# is_trashed=true (or rely on a SQL trigger on the parent
|
||||
# FK cascade).
|
||||
#
|
||||
# Test posture: pin the orphan behaviour. The folder→404 part
|
||||
# is the only correct half. When the fix lands, the
|
||||
# descendant assertions below will trip and you can flip them
|
||||
# to strict 404.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
echo " G9: DELETE folder (pinned: descendants currently orphan — KNOWN BUG)"
|
||||
nc_curl -o /dev/null -X MKCOL "$NC_FILES_BASE/g9-tree/" > /dev/null
|
||||
nc_curl -o /dev/null -X MKCOL "$NC_FILES_BASE/g9-tree/inner/" > /dev/null
|
||||
put_nc_file "g9-tree/file.txt" "G9 file"
|
||||
put_nc_file "g9-tree/inner/deep.txt" "G9 deep"
|
||||
STATUS=$(nc_curl -o /dev/null -w "%{http_code}" -X DELETE "$NC_FILES_BASE/g9-tree/")
|
||||
[[ "$STATUS" == "204" ]] \
|
||||
|| fail "G9: folder DELETE expected 204, got $STATUS"
|
||||
|
||||
# Folder itself: correctly 404.
|
||||
[[ "$(nc_status_propfind_depth0 "$NC_FILES_BASE/g9-tree/")" == "404" ]] \
|
||||
|| fail "G9: folder still present after DELETE — that part should always be 404"
|
||||
|
||||
# Descendants: pin the current (buggy) "still alive" status.
|
||||
# Either current 207 (bug) or future 404 (fix) is acceptable;
|
||||
# anything else means something has drifted unexpectedly.
|
||||
CHILD_STATUS=$(nc_status_propfind_depth0 "$NC_FILES_BASE/g9-tree/file.txt")
|
||||
DEEP_STATUS=$(nc_status_propfind_depth0 "$NC_FILES_BASE/g9-tree/inner/deep.txt")
|
||||
if [[ "$CHILD_STATUS" == "207" && "$DEEP_STATUS" == "207" ]]; then
|
||||
pass "G9: descendants still reachable (file=207, deep=207) — KNOWN BUG pinned: move_to_trash isn't recursive at the row level"
|
||||
elif [[ "$CHILD_STATUS" == "404" && "$DEEP_STATUS" == "404" ]]; then
|
||||
fail "G9: descendants now correctly 404 (file=$CHILD_STATUS, deep=$DEEP_STATUS) — bug is fixed, flip this case to strict 404 assertions."
|
||||
else
|
||||
fail "G9: mixed/unexpected descendant statuses (file=$CHILD_STATUS, deep=$DEEP_STATUS) — pin needs review"
|
||||
fi
|
||||
|
||||
# ═════════════════════════════════════════════════════════════
|
||||
# Group K — Trashbin DAV (depends on G8's deletion above)
|
||||
# ═════════════════════════════════════════════════════════════
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# K1 — PROPFIND trashbin: g8-doomed.txt present with the
|
||||
# original-location property.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
echo " K1: PROPFIND trashbin lists g8-doomed.txt"
|
||||
BODY=$(nc_curl -X PROPFIND -H "Depth: 1" "$NC_TRASH_BASE/")
|
||||
grep -q 'g8-doomed' <<< "$BODY" \
|
||||
|| fail "K1: g8-doomed.txt not in trashbin PROPFIND"
|
||||
grep -q '<nc:trashbin-original-location>' <<< "$BODY" \
|
||||
|| fail "K1: trashbin response missing <nc:trashbin-original-location>"
|
||||
pass "K1: trashbin shows g8-doomed.txt with original-location"
|
||||
|
||||
# Extract the trashed item id (last segment of the href).
|
||||
# Trashbin hrefs are `/remote.php/dav/trashbin/{user}/trash/{uuid}`
|
||||
# — they don't carry the filename, so we match the surrounding
|
||||
# `<d:response>` block by `<nc:trashbin-filename>g8-doomed…` and
|
||||
# pull THAT block's href.
|
||||
TRASHED_HREF=$(extract_response_href_containing "$BODY" "g8-doomed")
|
||||
[[ -n "$TRASHED_HREF" ]] || fail "K1: could not extract trashed href for g8-doomed.txt"
|
||||
TRASHED_ID=$(basename "$TRASHED_HREF")
|
||||
[[ -n "$TRASHED_ID" ]] || fail "K1: could not extract trashed item id from href '$TRASHED_HREF'"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# K2 — MOVE from trash → 201 (restore to ORIGINAL location)
|
||||
#
|
||||
# Pinned current behaviour: the trashbin MOVE handler IGNORES
|
||||
# the `Destination` request header. It extracts the trash UUID
|
||||
# from the URL path and calls `trash_service.restore_item(id,
|
||||
# user_id)`, which restores the file to its *original*
|
||||
# location, not to the URL the client requested. So even
|
||||
# though we send `Destination: /restored-g8.txt`, the file
|
||||
# ends up back at `/g8-doomed.txt`.
|
||||
#
|
||||
# This isn't necessarily a bug — many NC servers treat
|
||||
# trashbin MOVE as "restore to where it was" rather than as
|
||||
# arbitrary relocation. The NC desktop client doesn't rely on
|
||||
# the Destination here. But the wire shape diverges from
|
||||
# RFC 4918 §9.9, so it's worth pinning so a future drift in
|
||||
# either direction surfaces.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
echo " K2: MOVE from trash (Destination ignored, restores to ORIGINAL path /g8-doomed.txt)"
|
||||
STATUS=$(nc_curl -o /dev/null -w "%{http_code}" -X MOVE \
|
||||
-H "Destination: $NC_FILES_BASE/restored-g8.txt" \
|
||||
"$NC_TRASH_BASE/$TRASHED_ID")
|
||||
case "$STATUS" in
|
||||
201|204) pass "K2: trash MOVE → $STATUS (restore initiated)" ;;
|
||||
*) fail "K2: trash MOVE expected 201/204, got $STATUS" ;;
|
||||
esac
|
||||
# Pin: file is at its ORIGINAL path, NOT at the requested Destination.
|
||||
[[ "$(nc_status_propfind_depth0 "$NC_FILES_BASE/g8-doomed.txt")" == "207" ]] \
|
||||
|| fail "K2: file should have been restored to original /g8-doomed.txt — not found there"
|
||||
DEST_STATUS=$(nc_status_propfind_depth0 "$NC_FILES_BASE/restored-g8.txt")
|
||||
[[ "$DEST_STATUS" == "404" ]] \
|
||||
|| fail "K2: Destination header is now honoured (status $DEST_STATUS at requested dest) — behaviour changed; flip K2/K3 to RFC 4918 MOVE semantics."
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# K3 — Delete then permanently delete via trashbin DELETE
|
||||
#
|
||||
# K2 restored the file to its original path `/g8-doomed.txt`
|
||||
# (not `/restored-g8.txt`, see K2's pin), so we delete from
|
||||
# there to send it back to trash, then permanently delete via
|
||||
# the trashbin DELETE method.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
echo " K3: trashbin DELETE permanently removes an item"
|
||||
# Delete the just-restored file → goes back to trash.
|
||||
nc_curl -o /dev/null -X DELETE "$NC_FILES_BASE/g8-doomed.txt"
|
||||
BODY=$(nc_curl -X PROPFIND -H "Depth: 1" "$NC_TRASH_BASE/")
|
||||
TRASHED_HREF=$(extract_response_href_containing "$BODY" "g8-doomed")
|
||||
TRASHED_ID=$(basename "$TRASHED_HREF")
|
||||
[[ -n "$TRASHED_ID" && "$TRASHED_HREF" != "" ]] \
|
||||
|| fail "K3: g8-doomed.txt not in trash after re-delete (no matching <d:response> block)"
|
||||
STATUS=$(nc_curl -o /dev/null -w "%{http_code}" -X DELETE "$NC_TRASH_BASE/$TRASHED_ID")
|
||||
[[ "$STATUS" == "204" ]] \
|
||||
|| fail "K3: trash DELETE expected 204, got $STATUS"
|
||||
# Confirm it's gone from trash now.
|
||||
BODY=$(nc_curl -X PROPFIND -H "Depth: 1" "$NC_TRASH_BASE/")
|
||||
grep -q 'g8-doomed' <<< "$BODY" \
|
||||
&& fail "K3: g8-doomed still in trash after permanent DELETE"
|
||||
pass "K3: trashbin DELETE permanently removes the item"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# K4 — Empty all trash
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
echo " K4: DELETE on trash root empties everything"
|
||||
# Seed a few items
|
||||
put_nc_file "k4-a.txt" "k4 a"
|
||||
put_nc_file "k4-b.txt" "k4 b"
|
||||
put_nc_file "k4-c.txt" "k4 c"
|
||||
nc_curl -o /dev/null -X DELETE "$NC_FILES_BASE/k4-a.txt"
|
||||
nc_curl -o /dev/null -X DELETE "$NC_FILES_BASE/k4-b.txt"
|
||||
nc_curl -o /dev/null -X DELETE "$NC_FILES_BASE/k4-c.txt"
|
||||
|
||||
STATUS=$(nc_curl -o /dev/null -w "%{http_code}" -X DELETE "$NC_TRASH_BASE")
|
||||
[[ "$STATUS" == "204" ]] \
|
||||
|| fail "K4: empty-trash expected 204, got $STATUS"
|
||||
|
||||
BODY=$(nc_curl -X PROPFIND -H "Depth: 1" "$NC_TRASH_BASE/")
|
||||
N=$(count_responses "$BODY")
|
||||
# After empty, only the trash collection itself remains.
|
||||
[[ "$N" == "1" ]] \
|
||||
|| fail "K4: trash should have 1 response (collection only), got $N"
|
||||
pass "K4: empty-trash removes every item"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# K5 — Restore-collision behaviour (pinned)
|
||||
#
|
||||
# Restore a trashed item to a path where a same-named file
|
||||
# already exists. Pin whichever behaviour the server has today
|
||||
# (rename-suffix? 412? overwrite?) so a future change is loud.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
echo " K5: MOVE from trash to a colliding path — pin current behaviour"
|
||||
put_nc_file "k5-conflict.txt" "k5 original (stays)"
|
||||
put_nc_file "k5-doomed.txt" "k5 to trash and restore"
|
||||
nc_curl -o /dev/null -X DELETE "$NC_FILES_BASE/k5-doomed.txt"
|
||||
# Take the trashed id of k5-doomed.txt
|
||||
BODY=$(nc_curl -X PROPFIND -H "Depth: 1" "$NC_TRASH_BASE/")
|
||||
TRASHED_HREF=$(extract_response_href_containing "$BODY" "k5-doomed")
|
||||
TRASHED_ID=$(basename "$TRASHED_HREF")
|
||||
[[ -n "$TRASHED_ID" && "$TRASHED_HREF" != "" ]] \
|
||||
|| fail "K5: k5-doomed not in trash (no matching <d:response> block)"
|
||||
STATUS=$(nc_curl -o /dev/null -w "%{http_code}" -X MOVE \
|
||||
-H "Destination: $NC_FILES_BASE/k5-conflict.txt" \
|
||||
"$NC_TRASH_BASE/$TRASHED_ID")
|
||||
case "$STATUS" in
|
||||
201|204)
|
||||
pass "K5: restore-onto-existing → $STATUS (current behaviour pinned: collision NOT prevented at this layer)"
|
||||
;;
|
||||
412)
|
||||
pass "K5: restore-onto-existing → 412 (current behaviour pinned: precondition-style refusal)"
|
||||
;;
|
||||
409)
|
||||
pass "K5: restore-onto-existing → 409 (current behaviour pinned: name conflict)"
|
||||
;;
|
||||
500)
|
||||
# Same shape as the G4/G5 bug — restore is a MOVE under
|
||||
# the hood, and the handler doesn't catch the storage-
|
||||
# layer "Already Exists" before it becomes an internal
|
||||
# error. Pinned because that's the actual current
|
||||
# behaviour, not because it's correct.
|
||||
pass "K5: restore-onto-existing → 500 (KNOWN BUG: same root cause as G4/G5 — pinned)"
|
||||
;;
|
||||
*)
|
||||
fail "K5: unexpected status $STATUS — pin needs reviewing"
|
||||
;;
|
||||
esac
|
||||
|
||||
# ── Cleanup ──────────────────────────────────────────────────────────────────
|
||||
echo " cleanup: empty trash + remove residual fixtures"
|
||||
api_empty_trash || true
|
||||
pass "cleanup done"
|
||||
|
||||
# ── summary ───────────────────────────────────────────────────────────────────
|
||||
|
||||
echo
|
||||
echo "Results: $PASS passed, $FAIL failed."
|
||||
[[ "$FAIL" -eq 0 ]] && echo "All tests passed." || exit 1
|
||||
Executable
+316
@@ -0,0 +1,316 @@
|
||||
#!/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
|
||||
Executable
+324
@@ -0,0 +1,324 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================
|
||||
# OxiCloud — Baseline: NC WebDAV PUT / MKCOL + BLAKE3 round-trip
|
||||
# =============================================================
|
||||
# Group F from BASELINE_TESTS_NC_WEBDAV.md.
|
||||
#
|
||||
# Headline assertions:
|
||||
# F8 / F9 — local b3sum of the uploaded bytes equals the
|
||||
# server's FileDto.content_hash retrieved via the
|
||||
# REST API. Proves the hash-on-write streaming path
|
||||
# actually produces the canonical BLAKE3 — the
|
||||
# value every downstream dedup / lifecycle hook
|
||||
# keys on. This is the load-bearing check that
|
||||
# would have caught the `etag` vs `content_hash`
|
||||
# confusion (the 0135930d regression).
|
||||
#
|
||||
# Pinned behaviour notes (not bugs, but worth catching if any
|
||||
# change in either direction):
|
||||
# F5 / F6 — NC PUT does NOT process If-None-Match / If-Match
|
||||
# today. Both succeed with 201/204 instead of the
|
||||
# strict-RFC-4918 412. If a later commit adds
|
||||
# conditional support, the assertions here will
|
||||
# trip and you can update them deliberately.
|
||||
# F1 — NC PUT returns ETag + oc-etag headers but NO
|
||||
# oc-fileid header (the file id is discoverable
|
||||
# via PROPFIND or REST). Pinned as "absent".
|
||||
# =============================================================
|
||||
|
||||
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 PUT / MKCOL + BLAKE3 (Group F baseline) ==="
|
||||
echo
|
||||
|
||||
oxicloud_login
|
||||
mint_app_password
|
||||
resolve_home_folder_id
|
||||
wipe_home_folder # defensive against cross-test contamination
|
||||
|
||||
NC_FILES_BASE="$base_url/remote.php/dav/files/$username"
|
||||
|
||||
PUT_FIXTURE_DIR=$(mktemp -d)
|
||||
trap 'rm -rf "$PUT_FIXTURE_DIR"' EXIT
|
||||
|
||||
# ── Pre-flight: confirm `b3sum` is available ─────────────────────────────────
|
||||
command -v b3sum >/dev/null 2>&1 \
|
||||
|| fail "preflight: b3sum required for F8/F9 — install via 'brew install b3sum' or 'apt install b3sum'"
|
||||
|
||||
# ── Helper: extract the first ETag value from a curl `-D -` dump ─────────────
|
||||
header_value() {
|
||||
grep -i "^$1:" <<< "$2" | awk '{print $2}' | tr -d '\r"' | head -n 1
|
||||
}
|
||||
|
||||
# ── Helper: list home folder, find file by name, capture id + content_hash ───
|
||||
#
|
||||
# Uses `/listing` (NOT `/contents`): `/contents` is deprecated AND
|
||||
# its response shape was changed from `{files, folders}` to a flat
|
||||
# array, so callers that try `.files[]` fail with "Cannot index
|
||||
# array with string 'files'". The non-deprecated `/listing`
|
||||
# endpoint still returns the `.files[] / .folders[]` shape we
|
||||
# need here. Same endpoint `wipe_home_folder` + the API cleanup
|
||||
# audit (`tests/api/storage_cleanup_check.sh`) use.
|
||||
nc_lookup_via_rest() {
|
||||
local name="$1"
|
||||
local response
|
||||
response=$(api_curl "$base_url/api/folders/$HOME_FOLDER_ID/listing")
|
||||
LAST_FILE_ID=$(jq -r --arg n "$name" '.files[]? | select(.name == $n) | .id' <<< "$response")
|
||||
LAST_FILE_CONTENT_HASH=$(jq -r --arg n "$name" '.files[]? | select(.name == $n) | .content_hash' <<< "$response")
|
||||
[[ -n "$LAST_FILE_ID" && "$LAST_FILE_ID" != "null" ]] \
|
||||
|| fail "REST lookup for '$name' in home folder returned no id (response: $response)"
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# F1 — PUT a new file → 201 + ETag + oc-etag
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
echo " F1: PUT new file → 201"
|
||||
SMALL_CONTENT="hello from group F"
|
||||
SMALL_PATH="$PUT_FIXTURE_DIR/f1-small.txt"
|
||||
printf '%s' "$SMALL_CONTENT" > "$SMALL_PATH"
|
||||
SMALL_LEN=$(wc -c < "$SMALL_PATH" | tr -d ' ')
|
||||
|
||||
HEADERS=$(nc_curl -D - -o /dev/null -X PUT \
|
||||
-H "Content-Type: text/plain" \
|
||||
--data-binary "@$SMALL_PATH" \
|
||||
"$NC_FILES_BASE/f1-small.txt")
|
||||
STATUS=$(awk 'NR==1{print $2}' <<< "$HEADERS" | tr -d '\r')
|
||||
[[ "$STATUS" == "201" ]] \
|
||||
|| fail "F1: PUT new expected 201, got $STATUS"
|
||||
F1_ETAG=$(header_value "etag" "$HEADERS")
|
||||
F1_OC_ETAG=$(header_value "oc-etag" "$HEADERS")
|
||||
[[ -n "$F1_ETAG" ]] || fail "F1: response missing ETag header"
|
||||
[[ -n "$F1_OC_ETAG" ]] || fail "F1: response missing oc-etag header"
|
||||
[[ "$F1_ETAG" == "$F1_OC_ETAG" ]] \
|
||||
|| fail "F1: ETag ($F1_ETAG) and oc-etag ($F1_OC_ETAG) should match"
|
||||
# Pin "no oc-fileid header" current behaviour.
|
||||
grep -qi '^oc-fileid:' <<< "$HEADERS" \
|
||||
&& fail "F1: oc-fileid header is now present — pin needs updating"
|
||||
pass "F1: PUT new → 201 + matching ETag/oc-etag, no oc-fileid header"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# F2 — GET retrieves the bytes we just PUT
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
echo " F2: GET file just PUT"
|
||||
ACTUAL=$(nc_curl "$NC_FILES_BASE/f1-small.txt")
|
||||
[[ "$ACTUAL" == "$SMALL_CONTENT" ]] \
|
||||
|| fail "F2: body mismatch — got '$ACTUAL', expected '$SMALL_CONTENT'"
|
||||
pass "F2: GET returns exact bytes from F1's PUT"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# F3 — PUT overwrite same path → 204 + NEW ETag
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
echo " F3: PUT overwrite → 204 + new ETag"
|
||||
NEW_CONTENT="goodbye from group F"
|
||||
NEW_PATH="$PUT_FIXTURE_DIR/f3-overwrite.txt"
|
||||
printf '%s' "$NEW_CONTENT" > "$NEW_PATH"
|
||||
|
||||
HEADERS=$(nc_curl -D - -o /dev/null -X PUT \
|
||||
-H "Content-Type: text/plain" \
|
||||
--data-binary "@$NEW_PATH" \
|
||||
"$NC_FILES_BASE/f1-small.txt")
|
||||
STATUS=$(awk 'NR==1{print $2}' <<< "$HEADERS" | tr -d '\r')
|
||||
[[ "$STATUS" == "204" ]] \
|
||||
|| fail "F3: PUT overwrite expected 204, got $STATUS"
|
||||
F3_ETAG=$(header_value "etag" "$HEADERS")
|
||||
[[ -n "$F3_ETAG" ]] || fail "F3: overwrite response missing ETag"
|
||||
[[ "$F3_ETAG" != "$F1_ETAG" ]] \
|
||||
|| fail "F3: ETag must change on overwrite ($F1_ETAG → $F3_ETAG)"
|
||||
pass "F3: PUT overwrite → 204 + new ETag (different from F1)"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# F4 — GET after overwrite returns new content (cache regression
|
||||
# guard — see commit f4ce4092)
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
echo " F4: GET after overwrite returns NEW content"
|
||||
ACTUAL=$(nc_curl "$NC_FILES_BASE/f1-small.txt")
|
||||
[[ "$ACTUAL" == "$NEW_CONTENT" ]] \
|
||||
|| fail "F4: STALE content after overwrite — got '$ACTUAL', expected '$NEW_CONTENT' (regression of f4ce4092?)"
|
||||
pass "F4: GET after overwrite serves the new bytes (no stale-cache)"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# F5 / F6 — Conditional PUT (pinned: currently no-op)
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
echo " F5: PUT with If-None-Match: * on existing path (pinned current: 204, RFC-4918 would be 412)"
|
||||
HEADERS=$(nc_curl -D - -o /dev/null -X PUT \
|
||||
-H "If-None-Match: *" -H "Content-Type: text/plain" \
|
||||
--data-binary 'F5-payload' \
|
||||
"$NC_FILES_BASE/f1-small.txt")
|
||||
STATUS=$(awk 'NR==1{print $2}' <<< "$HEADERS" | tr -d '\r')
|
||||
[[ "$STATUS" == "204" || "$STATUS" == "201" ]] \
|
||||
|| fail "F5: unexpected status $STATUS (expected 204 — current ignore-conditional behaviour)"
|
||||
pass "F5: PUT honours no conditional headers today — pinned"
|
||||
|
||||
echo " F6: PUT with If-Match: \"wrong-etag\" (pinned current: succeeds, RFC-4918 would be 412)"
|
||||
HEADERS=$(nc_curl -D - -o /dev/null -X PUT \
|
||||
-H 'If-Match: "deadbeef-never-matches"' -H "Content-Type: text/plain" \
|
||||
--data-binary 'F6-payload' \
|
||||
"$NC_FILES_BASE/f1-small.txt")
|
||||
STATUS=$(awk 'NR==1{print $2}' <<< "$HEADERS" | tr -d '\r')
|
||||
[[ "$STATUS" == "204" || "$STATUS" == "201" ]] \
|
||||
|| fail "F6: unexpected status $STATUS (expected 204 — current ignore-conditional behaviour)"
|
||||
pass "F6: PUT honours no If-Match today — pinned"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# F7 — PUT a "large" file → succeeds, GET returns exact bytes
|
||||
#
|
||||
# Size is 3 MiB, deliberately just under `OXICLOUD_DIRECT_PUT_MAX_BYTES`
|
||||
# (4 MiB in the test env — see `tests/common/server.env`). Files
|
||||
# above that cap are expected to use the chunked-upload protocol,
|
||||
# which is Group J territory. 3 MiB is still big enough to exercise
|
||||
# the streaming-spool / hash-on-write path that F9 then validates
|
||||
# end-to-end via the BLAKE3 round-trip. The BASELINE doc said "10 MB"
|
||||
# but the test env constraint takes precedence.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
echo " F7: PUT 3 MiB random binary → 201/204 + GET returns same bytes"
|
||||
LARGE_PATH="$PUT_FIXTURE_DIR/f7-large.bin"
|
||||
dd if=/dev/urandom of="$LARGE_PATH" bs=1024 count=3072 status=none
|
||||
LARGE_LEN=$(wc -c < "$LARGE_PATH" | tr -d ' ')
|
||||
LARGE_LOCAL_HASH=$(b3sum --no-names "$LARGE_PATH" | awk '{print $1}')
|
||||
|
||||
# Disable `Expect: 100-continue` — curl sends it for large bodies,
|
||||
# and the resulting interim "HTTP/1.1 100 Continue" line would be
|
||||
# the FIRST line in the `-D -` dump, making `awk 'NR==1'` pick up
|
||||
# 100 instead of the final 201/204. The Expect handshake serves
|
||||
# no functional purpose for the test.
|
||||
HEADERS=$(nc_curl -D - -o /dev/null -X PUT \
|
||||
-H "Expect:" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary "@$LARGE_PATH" \
|
||||
"$NC_FILES_BASE/f7-large.bin")
|
||||
STATUS=$(awk 'NR==1{print $2}' <<< "$HEADERS" | tr -d '\r')
|
||||
[[ "$STATUS" == "201" || "$STATUS" == "204" ]] \
|
||||
|| fail "F7: PUT 3 MiB expected 201/204, got $STATUS"
|
||||
DOWNLOADED="$PUT_FIXTURE_DIR/f7-large.downloaded"
|
||||
nc_curl -o "$DOWNLOADED" "$NC_FILES_BASE/f7-large.bin"
|
||||
cmp -s "$LARGE_PATH" "$DOWNLOADED" \
|
||||
|| fail "F7: downloaded bytes differ from uploaded — streaming integrity broken"
|
||||
pass "F7: 3 MiB streamed PUT round-trips byte-identically"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# F8 — BLAKE3 round-trip (small file)
|
||||
#
|
||||
# Uses a dedicated path (`f8-blake3-probe.txt`) that no other
|
||||
# scenario in this script touches. F1-F6 all overwrite
|
||||
# `f1-small.txt` repeatedly, so by the time F8 runs the server
|
||||
# holds whatever F6's last PUT wrote (`F6-payload`), not what
|
||||
# F3 wrote — comparing F3's local b3sum against the server
|
||||
# would be a false mismatch. A fresh single-write fixture
|
||||
# isolates the BLAKE3 round-trip from the F1-F6 sequence.
|
||||
#
|
||||
# Verifies the streaming hash-on-write path produced the
|
||||
# canonical BLAKE3 the dedup/lifecycle layer expects.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
echo " F8: BLAKE3 round-trip (small file — local b3sum vs server content_hash)"
|
||||
F8_PATH="$PUT_FIXTURE_DIR/f8-probe.txt"
|
||||
printf 'f8 blake3 round-trip probe — single write, known bytes' > "$F8_PATH"
|
||||
F8_LOCAL_HASH=$(b3sum --no-names "$F8_PATH" | awk '{print $1}')
|
||||
nc_curl -X PUT -H "Content-Type: text/plain" \
|
||||
--data-binary "@$F8_PATH" \
|
||||
"$NC_FILES_BASE/f8-blake3-probe.txt" > /dev/null
|
||||
nc_lookup_via_rest "f8-blake3-probe.txt"
|
||||
[[ -n "$LAST_FILE_CONTENT_HASH" && "$LAST_FILE_CONTENT_HASH" != "null" ]] \
|
||||
|| fail "F8: REST returned empty content_hash for f8-blake3-probe.txt"
|
||||
[[ "$LAST_FILE_CONTENT_HASH" == "$F8_LOCAL_HASH" ]] \
|
||||
|| fail "F8: BLAKE3 mismatch — server '$LAST_FILE_CONTENT_HASH' vs local '$F8_LOCAL_HASH'"
|
||||
pass "F8: small-file content_hash matches local b3sum ($F8_LOCAL_HASH)"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# F9 — BLAKE3 round-trip (streamed file)
|
||||
#
|
||||
# Same check on the streaming hash-on-write path. The 3 MiB
|
||||
# upload from F7 exercises the streaming spool /
|
||||
# hasher.update / final blob promotion sequence — F8 only
|
||||
# validates the small-buffer path. Size was 10 MB in the
|
||||
# BASELINE doc; reduced to 3 MiB so it stays under the test
|
||||
# env's direct-PUT cap (see F7 comment).
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
echo " F9: BLAKE3 round-trip (3 MiB streamed file)"
|
||||
nc_lookup_via_rest "f7-large.bin"
|
||||
[[ -n "$LAST_FILE_CONTENT_HASH" && "$LAST_FILE_CONTENT_HASH" != "null" ]] \
|
||||
|| fail "F9: REST returned empty content_hash for f7-large.bin"
|
||||
[[ "$LAST_FILE_CONTENT_HASH" == "$LARGE_LOCAL_HASH" ]] \
|
||||
|| fail "F9: BLAKE3 mismatch on 3 MiB — server '$LAST_FILE_CONTENT_HASH' vs local '$LARGE_LOCAL_HASH'"
|
||||
pass "F9: 3 MiB streamed content_hash matches local b3sum"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# F10 — MKCOL creates a folder → 201
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
echo " F10: MKCOL new folder → 201"
|
||||
STATUS=$(nc_curl -o /dev/null -w "%{http_code}" -X MKCOL "$NC_FILES_BASE/f10-folder/")
|
||||
[[ "$STATUS" == "201" ]] \
|
||||
|| fail "F10: MKCOL new expected 201, got $STATUS"
|
||||
# PROPFIND it to confirm
|
||||
BODY=$(nc_curl -X PROPFIND -H "Depth: 0" "$NC_FILES_BASE/f10-folder/")
|
||||
grep -q '<d:collection/>' <<< "$BODY" \
|
||||
|| fail "F10: PROPFIND of just-created folder lacks <d:collection/>"
|
||||
pass "F10: MKCOL creates folder, PROPFIND sees it as a collection"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# F11 — MKCOL with missing intermediate parent
|
||||
#
|
||||
# Pinned current behaviour: OxiCloud's MKCOL auto-creates
|
||||
# missing intermediate parents (effectively `mkdir -p`
|
||||
# semantics). Sending MKCOL on `/a/b/c/` where neither `a` nor
|
||||
# `b` exists succeeds with 201 — both intermediates are
|
||||
# silently created.
|
||||
#
|
||||
# Strict RFC 4918 §9.3.1 requires 409 Conflict here ("when the
|
||||
# parent collection does not exist"). NC desktop tolerates
|
||||
# either behaviour (it always MKCOLs ancestors one at a time
|
||||
# during sync), so the auto-create behaviour is harmless in
|
||||
# practice — but if you ever want strict mode, the fix lives
|
||||
# in `interfaces/nextcloud/webdav_handler.rs::handle_mkcol`:
|
||||
# look up the parent path before creating; 409 if missing.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
echo " F11: MKCOL with missing parent (pinned: auto-creates parents, RFC-4918 would 409)"
|
||||
STATUS=$(nc_curl -o /dev/null -w "%{http_code}" -X MKCOL \
|
||||
"$NC_FILES_BASE/f11-nonexistent-parent/inner/")
|
||||
case "$STATUS" in
|
||||
201)
|
||||
pass "F11: MKCOL auto-created intermediate parents (201) — pinned current behaviour"
|
||||
;;
|
||||
409)
|
||||
fail "F11: server now returns 409 (RFC-4918 strict). Bug? Improvement? — review and update pin to strict assertion."
|
||||
;;
|
||||
*)
|
||||
fail "F11: unexpected status $STATUS"
|
||||
;;
|
||||
esac
|
||||
# Cleanup the auto-created parent so subsequent tests don't see it.
|
||||
nc_curl -o /dev/null -X DELETE "$NC_FILES_BASE/f11-nonexistent-parent/" > /dev/null 2>&1 || true
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# F12 — MKCOL on existing folder → 405
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
echo " F12: MKCOL on existing folder → 405"
|
||||
STATUS=$(nc_curl -o /dev/null -w "%{http_code}" -X MKCOL "$NC_FILES_BASE/f10-folder/")
|
||||
[[ "$STATUS" == "405" ]] \
|
||||
|| fail "F12: existing-folder MKCOL expected 405, got $STATUS"
|
||||
pass "F12: MKCOL on existing folder → 405"
|
||||
|
||||
# ── Cleanup ──────────────────────────────────────────────────────────────────
|
||||
|
||||
echo " cleanup: delete fixtures + empty trash"
|
||||
# Use the NC DELETE (covered in group G) to round-trip through the
|
||||
# same surface we're trying to baseline.
|
||||
nc_curl -o /dev/null -X DELETE "$NC_FILES_BASE/f1-small.txt" || true
|
||||
nc_curl -o /dev/null -X DELETE "$NC_FILES_BASE/f7-large.bin" || true
|
||||
nc_curl -o /dev/null -X DELETE "$NC_FILES_BASE/f8-blake3-probe.txt" || true
|
||||
nc_curl -o /dev/null -X DELETE "$NC_FILES_BASE/f10-folder/" || true
|
||||
api_empty_trash || true
|
||||
pass "cleanup done"
|
||||
|
||||
# ── summary ───────────────────────────────────────────────────────────────────
|
||||
|
||||
echo
|
||||
echo "Results: $PASS passed, $FAIL failed."
|
||||
[[ "$FAIL" -eq 0 ]] && echo "All tests passed." || exit 1
|
||||
Reference in New Issue
Block a user