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

    test scenarios are in docs/plan/BASELINE_TESTS_NC_WEBDAV.md

    current existing bugs identified via these tests:

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

196 lines
9.7 KiB
Bash
Executable File

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