Files
Oxicloud/tests/webdav/test_nc_chunked_upload_assembly_blake3.sh
T
Bradley Nelson e3823ce470 test(e2e): Playwright + Vitest coverage harness and test instrumentation
Add an end-to-end and unit test suite for the SvelteKit frontend:

- Playwright e2e specs (tests/e2e/spa) with a throwaway container stack,
  codegen scenarios, and an Istanbul-based coverage report pipeline.
- Vitest unit tests across API endpoints, components, stores and composables.
- `data-testid` hooks on interactive elements (AppShell, FileViewer,
  ShareDialog, search, photos, files breadcrumbs, login/Nextcloud flows,
  public share pages) so the e2e suite can target them deterministically.
- Serve the SPA app-shell CSP from a <meta> policy (svelte.config.js) plus a
  middleware that skips the CSP header on HTML; move the Nextcloud Login Flow
  v2 grant page to the SvelteKit /nextcloud/login route.
- `just front-codegen` recipe and start-server-spa.sh harness.

Make the test environment robust and consistent:
- Install a deterministic in-memory localStorage/sessionStorage in the Vitest
  setup so storage behaves identically across Node versions (Node 26 ships a
  native Web Storage global that otherwise shadows jsdom's).
- Pin devenv to Node 26 + PostgreSQL 18 and pin every CI job to Node 26.3.0
  so the dev shell and CI run the same toolchain versions.

Repair the API/WebDAV (hurl) suite, which had drifted from the backend:
- Migrate the removed `/api/folders/{id}/listing` endpoint to `/resources`
  (cursor-paginated `{items:[{resource_type,resource}]}` shape) across the
  batch-copy, grants, nested-group, and WebDAV NC tests + the dav_helpers
  wipe routine.
- Stop photos_etag from uploading the dedup-tracked fixture so the dedup
  blob-lifecycle test can own its content-addressed blob exclusively.
- dedup_create now asserts the idempotent same-content re-upload (201 +
  existing file id) instead of the stale 409 expectation.

Generated coverage reports, nyc output and the e2e server runtime data dir
are gitignored rather than committed.
2026-06-22 00:05:06 -06:00

252 lines
14 KiB
Bash
Executable File

#!/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 `/resources` listing
# ({ items: [{ resource_type, resource }] }; `/listing` was removed).
listing=$(api_curl "$base_url/api/folders/$HOME_FOLDER_ID/resources?resource_types=file&limit=200")
ASSEMBLED_ID=$(jq -r '.items[]?.resource | select(.name == "j-assembled.bin") | .id' <<< "$listing")
SERVER_HASH=$(jq -r '.items[]?.resource | 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