311 lines
14 KiB
Bash
Executable File
311 lines
14 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# =============================================================
|
|
# OxiCloud — NextCloud chunked upload cap + streaming check
|
|
# =============================================================
|
|
# Validates the per-chunk `storage.chunk_max_bytes` cap on the
|
|
# NextCloud-compat chunked-upload surface (`/remote.php/dav/uploads/`),
|
|
# and round-trips a small file through MKCOL → PUT → MOVE to
|
|
# prove the streaming write at `handle_put_chunk` produces a
|
|
# byte-exact blob on disk.
|
|
#
|
|
# Sister test of `tests/api/chunked_upload_cap.hurl` (REST chunked).
|
|
# Both surfaces share the same `chunk_max_bytes` cap and the same
|
|
# `stream_body_to_path` helper; this test exercises the NC half:
|
|
# Basic Auth via app-password, WebDAV verbs, fewer protocol
|
|
# affordances than the REST API.
|
|
#
|
|
# Cases covered:
|
|
# 1. SUCCESS — MKCOL → PUT a single chunk (hello.txt, 32 B) →
|
|
# MOVE → verify the assembled file's BLAKE3 over REST.
|
|
# 2. CAP REJECTION — MKCOL a fresh session → PUT a 5 MiB chunk →
|
|
# 413 Payload Too Large.
|
|
# 3. QUOTA REJECTION (D4 / per-chunk gate) — tighten the caller's
|
|
# storage envelope to 100 B, MKCOL a fresh session (still under
|
|
# cap, used=0), then PUT a 200 B chunk → 507 Insufficient
|
|
# Storage. Pre-D4 the chunked path never gated until the final
|
|
# MOVE — clients could waste GB of upload before learning they
|
|
# were over. Validates `refuse_if_over_quota` in
|
|
# `uploads_handler::handle_put_chunk` runs the
|
|
# `used + session_so_far + content_length` projection
|
|
# ahead of accepting body bytes. Admin's original quota is
|
|
# restored on exit so subsequent tests in the suite are
|
|
# unaffected.
|
|
#
|
|
# Prerequisites:
|
|
# - Server running at $base_url with admin credentials (test.env).
|
|
# - OXICLOUD_ENABLE_AUTH=true, OXICLOUD_NEXTCLOUD_ENABLED=true.
|
|
# - tests/fixtures/chunk-over-cap-5mb.bin generated by
|
|
# tests/api/run.sh (or by hand: dd if=/dev/zero of=… bs=1024 count=5120).
|
|
# - jq, dd, curl in PATH.
|
|
# =============================================================
|
|
|
|
set -euo pipefail
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
|
cd "$SCRIPT_DIR"
|
|
|
|
source test.env
|
|
source common.sh
|
|
|
|
# ── helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
PASS=0
|
|
FAIL=0
|
|
|
|
pass() { PASS=$(( PASS + 1 )); echo " PASS: $*"; }
|
|
fail() { FAIL=$(( FAIL + 1 )); echo " FAIL: $*" >&2; exit 1; }
|
|
|
|
rest_get() { curl -s -H "Authorization: Bearer $TOKEN" "$base_url$1"; }
|
|
rest_delete() { curl -s -o /dev/null -w "%{http_code}" -X DELETE -H "Authorization: Bearer $TOKEN" "$base_url$1"; }
|
|
|
|
purge_from_trash() {
|
|
local name="$1"
|
|
local tid
|
|
tid=$(rest_get "/api/trash/resources" \
|
|
| jq -r --arg n "$name" 'first(.items[] | select(.resource.name == $n) | .resource.id) // empty')
|
|
[[ -n "$tid" ]] && rest_delete "/api/trash/$tid" > /dev/null || true
|
|
}
|
|
|
|
# Helper: NC WebDAV request with Basic Auth, prints HTTP status.
|
|
nc_req() {
|
|
local method="$1" url="$2"
|
|
shift 2
|
|
curl -s -o /dev/null -w "%{http_code}" -X "$method" \
|
|
-u "$username:$APP_PASSWORD" \
|
|
"$@" \
|
|
"$base_url$url"
|
|
}
|
|
|
|
# ── fixtures ──────────────────────────────────────────────────────────────────
|
|
|
|
FIXTURE_SMALL="$REPO_ROOT/tests/fixtures/hello.txt"
|
|
FIXTURE_BIG="$REPO_ROOT/tests/fixtures/chunk-over-cap-5mb.bin"
|
|
[[ -f "$FIXTURE_SMALL" ]] || { echo "Missing fixture: $FIXTURE_SMALL" >&2; exit 1; }
|
|
# Self-generate the 5 MiB fixture if absent — the Hurl runner
|
|
# (`tests/api/run.sh`) generates it for the REST cap test; this
|
|
# script may run standalone, so we don't depend on that runner
|
|
# having executed first.
|
|
if [[ ! -s "$FIXTURE_BIG" ]]; then
|
|
echo " Generating 5 MiB fixture → $FIXTURE_BIG"
|
|
dd if=/dev/zero of="$FIXTURE_BIG" bs=1024 count=5120 status=none
|
|
fi
|
|
|
|
# BLAKE3 of hello.txt (32 B). Asserted against `content_hash` in
|
|
# the file DTO after the round trip — proves streaming wrote the
|
|
# exact bytes with no truncation / off-by-one.
|
|
EXPECTED_BLAKE3="b2208c5dc33ff951227bd0c139f5eccb04105d6da6a7519ee23f7bc00a17bb5a"
|
|
|
|
REMOTE_NAME="nc-chunked-cap-test.txt"
|
|
UPLOAD_ID_OK="oxi-cap-ok-$(date +%s)"
|
|
UPLOAD_ID_BIG="oxi-cap-big-$(date +%s)"
|
|
UPLOAD_ID_QUOTA="oxi-cap-quota-$(date +%s)"
|
|
# 200 B fixture for the D4 quota-gate case. Lives in $TMPDIR so it
|
|
# never lands in `tests/fixtures/` — generated on the fly, deleted
|
|
# by the EXIT trap. mktemp keeps the path race-free across parallel
|
|
# runs.
|
|
FIXTURE_200B=""
|
|
|
|
echo
|
|
echo "=== NextCloud chunked upload: cap + streaming ==="
|
|
echo
|
|
|
|
# ── authenticate ──────────────────────────────────────────────────────────────
|
|
|
|
oxicloud_login
|
|
|
|
# Mint an NC app password — NC endpoints use Basic Auth, not JWT.
|
|
APP_PASSWORD_RESPONSE=$(curl -s -X POST \
|
|
-H "Authorization: Bearer $TOKEN" \
|
|
-H "Content-Type: application/json" \
|
|
-d '{"label":"chunked-cap-test","scopes":"webdav"}' \
|
|
"$base_url/api/auth/app-passwords")
|
|
|
|
APP_PASSWORD=$(jq -r '.password' <<<"$APP_PASSWORD_RESPONSE")
|
|
APP_PASSWORD_ID=$(jq -r '.id' <<<"$APP_PASSWORD_RESPONSE")
|
|
[[ -n "$APP_PASSWORD" && "$APP_PASSWORD" != "null" ]] \
|
|
|| fail "Failed to mint NC app password: $APP_PASSWORD_RESPONSE"
|
|
echo " app password minted (id=$APP_PASSWORD_ID)"
|
|
|
|
# Capture admin's id + current envelope quota up front so Case 3
|
|
# can tighten the cap and the EXIT trap can restore it on any
|
|
# failure path. `storage_quota_bytes == 0` is the unlimited
|
|
# sentinel (see `check_storage_quota`); we read it back here in
|
|
# case a prior test set a real value.
|
|
ADMIN_ID=$(rest_get "/api/auth/me" | jq -r '.full.user.id')
|
|
[[ -n "$ADMIN_ID" && "$ADMIN_ID" != "null" ]] || fail "Failed to read admin user id"
|
|
ORIGINAL_ADMIN_QUOTA=$(rest_get "/api/auth/me" | jq -r '.full.storage_quota_bytes // 0')
|
|
|
|
# Single cleanup on exit:
|
|
# - restore admin's original storage envelope (in case Case 3
|
|
# fired and we exited before its own restore),
|
|
# - revoke the test app password,
|
|
# - drop the on-the-fly 200 B fixture.
|
|
cleanup_test() {
|
|
if [[ -n "${ADMIN_ID:-}" ]]; then
|
|
curl -s -X PUT \
|
|
-H "Authorization: Bearer $TOKEN" \
|
|
-H "Content-Type: application/json" \
|
|
-d "{\"quota_bytes\":${ORIGINAL_ADMIN_QUOTA}}" \
|
|
"$base_url/api/admin/users/$ADMIN_ID/quota" > /dev/null || true
|
|
fi
|
|
if [[ -n "${APP_PASSWORD_ID:-}" ]]; then
|
|
rest_delete "/api/auth/app-passwords/$APP_PASSWORD_ID" > /dev/null || true
|
|
fi
|
|
[[ -n "${FIXTURE_200B:-}" && -f "$FIXTURE_200B" ]] && rm -f "$FIXTURE_200B"
|
|
}
|
|
trap cleanup_test EXIT
|
|
|
|
# Idempotent cleanup of any leftover file from a previous failed run.
|
|
HOME_FOLDER_ID=$(rest_get "/api/folders" | jq -r '.[0].id')
|
|
EXISTING_ID=$(rest_get "/api/files?folder_id=$HOME_FOLDER_ID" \
|
|
| jq -r --arg n "$REMOTE_NAME" 'first(.[] | select(.name == $n) | .id) // empty')
|
|
if [[ -n "$EXISTING_ID" ]]; then
|
|
echo " cleaning up leftover $REMOTE_NAME (id=$EXISTING_ID)"
|
|
rest_delete "/api/files/$EXISTING_ID" > /dev/null
|
|
purge_from_trash "$REMOTE_NAME"
|
|
fi
|
|
|
|
# ── Case 1: SUCCESS path ──────────────────────────────────────────────────────
|
|
|
|
echo
|
|
echo "[1/3] SUCCESS path — MKCOL → PUT → MOVE → verify BLAKE3"
|
|
|
|
# 1a. Create chunked-upload session.
|
|
STATUS=$(nc_req MKCOL "/remote.php/dav/uploads/$username/$UPLOAD_ID_OK")
|
|
[[ "$STATUS" =~ ^(201|204)$ ]] || fail "MKCOL upload session: got $STATUS, expected 201/204"
|
|
pass "MKCOL upload session (status=$STATUS)"
|
|
|
|
# 1b. PUT a single chunk (the whole 32-byte file).
|
|
STATUS=$(nc_req PUT \
|
|
"/remote.php/dav/uploads/$username/$UPLOAD_ID_OK/00001" \
|
|
-H "Content-Type: application/octet-stream" \
|
|
--data-binary "@$FIXTURE_SMALL")
|
|
[[ "$STATUS" == "201" ]] || fail "PUT chunk: got $STATUS, expected 201"
|
|
pass "PUT chunk (status=$STATUS)"
|
|
|
|
# 1c. MOVE to assemble into the final destination.
|
|
STATUS=$(nc_req MOVE \
|
|
"/remote.php/dav/uploads/$username/$UPLOAD_ID_OK/.file" \
|
|
-H "Destination: $base_url/remote.php/dav/files/$username/$REMOTE_NAME")
|
|
[[ "$STATUS" =~ ^(201|204)$ ]] || fail "MOVE assemble: got $STATUS, expected 201/204"
|
|
pass "MOVE assemble (status=$STATUS)"
|
|
|
|
# 1d. Verify the assembled file landed with the right BLAKE3.
|
|
ASSEMBLED=$(rest_get "/api/files?folder_id=$HOME_FOLDER_ID" \
|
|
| jq -r --arg n "$REMOTE_NAME" 'first(.[] | select(.name == $n))')
|
|
ACTUAL_HASH=$(jq -r '.content_hash' <<<"$ASSEMBLED")
|
|
ACTUAL_SIZE=$(jq -r '.size' <<<"$ASSEMBLED")
|
|
FILE_ID=$(jq -r '.id' <<<"$ASSEMBLED")
|
|
|
|
[[ "$ACTUAL_SIZE" == "32" ]] || fail "Assembled file size: got $ACTUAL_SIZE, expected 32"
|
|
[[ "$ACTUAL_HASH" == "$EXPECTED_BLAKE3" ]] \
|
|
|| fail "BLAKE3 mismatch: got $ACTUAL_HASH, expected $EXPECTED_BLAKE3"
|
|
pass "Assembled file size + BLAKE3 match fixture"
|
|
|
|
# 1e. Cleanup the assembled file so a re-run finds a clean slate.
|
|
rest_delete "/api/files/$FILE_ID" > /dev/null
|
|
purge_from_trash "$REMOTE_NAME"
|
|
|
|
# ── Case 2: CAP REJECTION ─────────────────────────────────────────────────────
|
|
|
|
echo
|
|
echo "[2/3] CAP REJECTION — 5 MiB chunk on a 4 MiB cap → 413"
|
|
|
|
# 2a. Fresh session.
|
|
STATUS=$(nc_req MKCOL "/remote.php/dav/uploads/$username/$UPLOAD_ID_BIG")
|
|
[[ "$STATUS" =~ ^(201|204)$ ]] || fail "MKCOL over-cap session: got $STATUS"
|
|
pass "MKCOL over-cap session (status=$STATUS)"
|
|
|
|
# 2b. PUT a 5 MiB chunk → expect 413. Pre-fix this would either OOM
|
|
# the server (the body was buffered up to `max_upload_size`,
|
|
# which is the *whole-file* cap, multi-GB) or — depending on the
|
|
# code path — succeed silently and assemble a corrupted file.
|
|
STATUS=$(nc_req PUT \
|
|
"/remote.php/dav/uploads/$username/$UPLOAD_ID_BIG/00001" \
|
|
-H "Content-Type: application/octet-stream" \
|
|
--data-binary "@$FIXTURE_BIG")
|
|
[[ "$STATUS" == "413" ]] || fail "PUT over-cap chunk: got $STATUS, expected 413"
|
|
pass "PUT over-cap chunk rejected (status=$STATUS)"
|
|
|
|
# 2c. Abort the leftover session — the cap-rejected PUT removed the
|
|
# partial chunk file, but the session metadata is still in
|
|
# `chunked_uploads/`. DELETE on the session dir cleans it.
|
|
STATUS=$(nc_req DELETE "/remote.php/dav/uploads/$username/$UPLOAD_ID_BIG")
|
|
[[ "$STATUS" =~ ^(204|404)$ ]] || fail "DELETE abandoned session: got $STATUS"
|
|
pass "DELETE abandoned session (status=$STATUS)"
|
|
|
|
# ── Case 3: QUOTA REJECTION (D4 per-chunk gate) ───────────────────────────────
|
|
|
|
echo
|
|
echo "[3/3] QUOTA REJECTION — envelope tightened to (used + 100 B), PUT 200 B chunk → 507"
|
|
|
|
# 3a. Compute the tight quota dynamically: admin has accumulated
|
|
# `used_bytes` from every earlier test in the suite, so a hard-
|
|
# coded "100 B" cap would trip MKCOL (`used + 0 > 100`). Read
|
|
# the current cached envelope and set the cap to
|
|
# `current + 100` — leaves enough headroom that MKCOL passes
|
|
# (`used + 0 = used < used + 100`) while a 200 B chunk PUT
|
|
# overflows by exactly 100 (`used + 0 + 200 > used + 100`).
|
|
CURRENT_USED=$(rest_get "/api/auth/me" | jq -r '.full.storage_used_bytes')
|
|
[[ -n "$CURRENT_USED" && "$CURRENT_USED" != "null" ]] || fail "Failed to read current used_bytes"
|
|
TIGHT_QUOTA=$(( CURRENT_USED + 100 ))
|
|
|
|
# 3b. Tighten admin's storage envelope. The pre-existing
|
|
# `cleanup_test` EXIT trap restores `ORIGINAL_ADMIN_QUOTA` so a
|
|
# mid-test failure doesn't leave the suite running under a
|
|
# barely-headroom cap.
|
|
curl -s -X PUT \
|
|
-H "Authorization: Bearer $TOKEN" \
|
|
-H "Content-Type: application/json" \
|
|
-d "{\"quota_bytes\":${TIGHT_QUOTA}}" \
|
|
"$base_url/api/admin/users/$ADMIN_ID/quota" > /dev/null
|
|
|
|
# 3c. Generate the 200 B fixture in $TMPDIR — never lands in
|
|
# `tests/fixtures/` (avoids polluting the committed dir + the
|
|
# gitignore list).
|
|
FIXTURE_200B=$(mktemp -t nc-chunked-quota-200b.XXXXXX.bin)
|
|
dd if=/dev/zero of="$FIXTURE_200B" bs=1 count=200 status=none
|
|
|
|
# 3d. Fresh session. MKCOL gate projects `used + 0` against the
|
|
# tight cap; with quota = used + 100 the projection sits 100 B
|
|
# under the limit so MKCOL passes. The real gate fires at the
|
|
# PUT below.
|
|
STATUS=$(nc_req MKCOL "/remote.php/dav/uploads/$username/$UPLOAD_ID_QUOTA")
|
|
[[ "$STATUS" =~ ^(201|204)$ ]] || fail "MKCOL quota session: got $STATUS"
|
|
pass "MKCOL quota session (status=$STATUS)"
|
|
|
|
# 3e. PUT a 200 B chunk → expect 507. The handler reads
|
|
# Content-Length (200), sums on-disk chunks for this session
|
|
# (0), and runs `check_storage_quota(admin_id, 200)`:
|
|
# used + 200 > used + 100 → QuotaExceeded → 507.
|
|
# Pre-D4 this would have returned 201 and the whole upload
|
|
# would have wasted bandwidth until the final MOVE.
|
|
STATUS=$(nc_req PUT \
|
|
"/remote.php/dav/uploads/$username/$UPLOAD_ID_QUOTA/00001" \
|
|
-H "Content-Type: application/octet-stream" \
|
|
--data-binary "@$FIXTURE_200B")
|
|
[[ "$STATUS" == "507" ]] || fail "PUT over-quota chunk: got $STATUS, expected 507"
|
|
pass "PUT over-quota chunk rejected (status=$STATUS)"
|
|
|
|
# 3e. Abort the leftover session.
|
|
STATUS=$(nc_req DELETE "/remote.php/dav/uploads/$username/$UPLOAD_ID_QUOTA")
|
|
[[ "$STATUS" =~ ^(204|404)$ ]] || fail "DELETE quota session: got $STATUS"
|
|
pass "DELETE quota session (status=$STATUS)"
|
|
|
|
# 3f. Restore admin's original envelope immediately — keeps the rest
|
|
# of the suite running under the right cap. The EXIT trap also
|
|
# restores it as belt-and-braces.
|
|
curl -s -X PUT \
|
|
-H "Authorization: Bearer $TOKEN" \
|
|
-H "Content-Type: application/json" \
|
|
-d "{\"quota_bytes\":${ORIGINAL_ADMIN_QUOTA}}" \
|
|
"$base_url/api/admin/users/$ADMIN_ID/quota" > /dev/null
|
|
pass "Admin envelope restored to ${ORIGINAL_ADMIN_QUOTA}"
|
|
|
|
# ── summary ──────────────────────────────────────────────────────────────────
|
|
|
|
echo
|
|
echo "=== NC chunked upload cap test: $PASS passed, $FAIL failed ==="
|
|
[[ "$FAIL" == "0" ]] || exit 1
|