test: full caldav + carddav test suite
This commit is contained in:
Executable
+215
@@ -0,0 +1,215 @@
|
||||
#!/usr/bin/env bash
|
||||
# CalDAV end-to-end conformance test using python-caldav.
|
||||
#
|
||||
# python-caldav (https://github.com/python-caldav/caldav) is the same
|
||||
# maintained client library used to test radicale, xandikos, davical.
|
||||
# Driving OxiCloud through it exercises the code paths that real
|
||||
# clients (Thunderbird, Apple Calendar, Gnome Calendar, DAVx⁵) hit —
|
||||
# it's the closest cognate to what `litmus` does for WebDAV, but for
|
||||
# the CalDAV surface.
|
||||
#
|
||||
# Usage (from repo root via justfile):
|
||||
# just test-caldav
|
||||
#
|
||||
# Or directly:
|
||||
# bash tests/caldav/run-pycaldav.sh
|
||||
#
|
||||
# Requires: python3 (>= 3.10 for python-caldav 1.x), curl, jq, docker
|
||||
# The `caldav` library + pytest are installed into a per-run venv at
|
||||
# `tests/caldav/.venv/`, gitignored.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
COMMON="$REPO_ROOT/tests/common"
|
||||
CALDAV_DIR="$REPO_ROOT/tests/caldav"
|
||||
|
||||
# shellcheck source=test.env
|
||||
source "$CALDAV_DIR/test.env"
|
||||
|
||||
SERVER_PORT="${base_url##*:}"
|
||||
|
||||
log() { echo "[caldav] $*"; }
|
||||
die() { echo "[caldav] ERROR: $*" >&2; exit 1; }
|
||||
|
||||
# ── Dependency checks ─────────────────────────────────────────────────────────
|
||||
|
||||
if ! command -v python3 >/dev/null 2>&1; then
|
||||
die "python3 not found. Install a recent Python 3."
|
||||
fi
|
||||
if ! command -v jq >/dev/null 2>&1; then
|
||||
die "jq not found."
|
||||
fi
|
||||
if ! command -v curl >/dev/null 2>&1; then
|
||||
die "curl not found."
|
||||
fi
|
||||
|
||||
# ── Teardown (always runs on exit) ────────────────────────────────────────────
|
||||
|
||||
SERVER_PID=""
|
||||
|
||||
SUITE_EXIT=0
|
||||
|
||||
cleanup() {
|
||||
# If pytest failed, show the last chunk of server log so
|
||||
# someone debugging doesn't have to hunt for the file.
|
||||
if [[ $SUITE_EXIT -ne 0 && -n "${SERVER_LOG:-}" && -f "$SERVER_LOG" ]]; then
|
||||
log "── server log tail (last 40 lines) ─────────────────────────"
|
||||
tail -n 40 "$SERVER_LOG" >&2
|
||||
log "── end server log tail ─────────────────────────────────────"
|
||||
fi
|
||||
if [[ -n "$SERVER_PID" ]]; then
|
||||
log "Stopping OxiCloud (pid $SERVER_PID)..."
|
||||
kill "$SERVER_PID" 2>/dev/null || true
|
||||
wait "$SERVER_PID" 2>/dev/null || true
|
||||
fi
|
||||
bash "$COMMON/stop-db.sh"
|
||||
}
|
||||
|
||||
trap cleanup EXIT
|
||||
|
||||
# ── 1. Start postgres ────────────────────────────────────────────────────────
|
||||
|
||||
bash "$COMMON/spawn-db.sh"
|
||||
|
||||
# ── 2. Start OxiCloud ────────────────────────────────────────────────────────
|
||||
|
||||
set -a
|
||||
# shellcheck source=../common/server.env
|
||||
source "$COMMON/server.env"
|
||||
OXICLOUD_SERVER_PORT=$SERVER_PORT
|
||||
OXICLOUD_STORAGE_PATH="$CALDAV_DIR/storage"
|
||||
set +a
|
||||
|
||||
# Wipe storage between runs so a stale run doesn't leak into fresh state.
|
||||
# Regex-gated via wipe-storage.sh so we can never `rm -rf /`.
|
||||
# shellcheck source=../common/wipe-storage.sh
|
||||
source "$COMMON/wipe-storage.sh"
|
||||
wipe_storage "$OXICLOUD_STORAGE_PATH"
|
||||
|
||||
BUILD_TARGET="${BUILD_TARGET:-debug}"
|
||||
OXICLOUD_BIN="$REPO_ROOT/target/$BUILD_TARGET/oxicloud"
|
||||
|
||||
# ALWAYS build — a `cargo check` / `cargo clippy` during development
|
||||
# leaves the target/ metadata fresh but NEVER produces or updates the
|
||||
# binary at target/<profile>/oxicloud. Skipping the rebuild on
|
||||
# "binary already exists" then runs pytest against a stale binary,
|
||||
# which manifests as impossible-looking test failures (e.g. "phase 3
|
||||
# routing broken" when the binary is from phase 2). Cargo's
|
||||
# incremental compile makes this near-free when nothing changed.
|
||||
log "Building OxiCloud ($BUILD_TARGET) — incremental compile, fast when up-to-date..."
|
||||
case "$BUILD_TARGET" in
|
||||
debug) (cd "$REPO_ROOT" && cargo build 2>&1 | tail -n 20) || die "cargo build failed" ;;
|
||||
release) (cd "$REPO_ROOT" && cargo build --release 2>&1 | tail -n 20) || die "cargo build --release failed" ;;
|
||||
*) die "Unsupported BUILD_TARGET='$BUILD_TARGET' (expected 'debug' or 'release')" ;;
|
||||
esac
|
||||
|
||||
[[ -x "$OXICLOUD_BIN" ]] || die "Build completed but $OXICLOUD_BIN is missing"
|
||||
|
||||
log "Starting OxiCloud ($BUILD_TARGET) on port $SERVER_PORT..."
|
||||
# `--config` pins the env file, suppressing the default `.env` probe so
|
||||
# a developer's repo-root `.env` can never leak into a test run.
|
||||
#
|
||||
# Redirect server stdout/stderr to a log file — otherwise every audit
|
||||
# line + tower-http error line interleaves with pytest's per-test
|
||||
# output, drowning PASSED/XFAIL markers under log spam. Cat the tail
|
||||
# of the log on cleanup so failures still surface the last events.
|
||||
SERVER_LOG="$CALDAV_DIR/server.log"
|
||||
: > "$SERVER_LOG"
|
||||
"$OXICLOUD_BIN" --config "$COMMON/server.env" >"$SERVER_LOG" 2>&1 &
|
||||
SERVER_PID=$!
|
||||
log "Server log: $SERVER_LOG (tail -f to watch live)"
|
||||
|
||||
log "Waiting for server at $base_url..."
|
||||
deadline=$(( $(date +%s) + 60 ))
|
||||
until curl -sf "$base_url/ready" >/dev/null 2>&1; do
|
||||
[[ $(date +%s) -ge $deadline ]] && die "Server did not become ready within 60s"
|
||||
sleep 1
|
||||
done
|
||||
log "Server ready."
|
||||
|
||||
# ── 3. Bootstrap admin + app password ────────────────────────────────────────
|
||||
|
||||
SETUP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||
-X POST -H "Content-Type: application/json" \
|
||||
-d "{\"username\":\"$username\",\"email\":\"$email\",\"password\":\"$password\"}" \
|
||||
"$base_url/api/setup")
|
||||
case "$SETUP_STATUS" in
|
||||
201) log "Admin account created." ;;
|
||||
403) log "Admin account already exists." ;;
|
||||
*) die "Unexpected /api/setup status: $SETUP_STATUS" ;;
|
||||
esac
|
||||
|
||||
LOGIN_RESP=$(curl -s -X POST -H "Content-Type: application/json" \
|
||||
-d "{\"username\":\"$username\",\"password\":\"$password\"}" \
|
||||
"$base_url/api/auth/login")
|
||||
JWT=$(jq -r '.access_token' <<<"$LOGIN_RESP")
|
||||
[[ -z "$JWT" || "$JWT" == "null" ]] && die "Login failed: $LOGIN_RESP"
|
||||
log "Logged in as $username."
|
||||
|
||||
# Real CalDAV clients authenticate via app password (Basic Auth), not
|
||||
# JWT — same rule as WebDAV. Session/account passwords are deliberately
|
||||
# refused on DAV surfaces (memory: DAV surfaces require app passwords
|
||||
# only). python-caldav uses HTTP Basic; the app password IS the credential.
|
||||
APP_PW_RESP=$(curl -s -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $JWT" \
|
||||
-d '{"label":"pycaldav-test"}' \
|
||||
"$base_url/api/auth/app-passwords")
|
||||
APP_PASSWORD=$(jq -r '.password' <<<"$APP_PW_RESP")
|
||||
[[ -z "$APP_PASSWORD" || "$APP_PASSWORD" == "null" ]] && die "App password creation failed: $APP_PW_RESP"
|
||||
log "App password created."
|
||||
|
||||
# ── 4. Python venv + install caldav + pytest ─────────────────────────────────
|
||||
|
||||
VENV="$CALDAV_DIR/.venv"
|
||||
if [[ ! -d "$VENV" ]]; then
|
||||
log "Creating Python venv at $VENV..."
|
||||
python3 -m venv "$VENV"
|
||||
fi
|
||||
# shellcheck source=/dev/null
|
||||
source "$VENV/bin/activate"
|
||||
|
||||
# Pin the major to avoid a surprise API break on `caldav` 2.x if/when
|
||||
# that lands. `pytest` version is loose — no reason to over-constrain
|
||||
# a test-only dep.
|
||||
if ! python3 -c "import caldav" 2>/dev/null; then
|
||||
log "Installing python-caldav + pytest into venv..."
|
||||
pip install --quiet 'caldav>=1.3,<2.0' 'pytest>=7,<9'
|
||||
fi
|
||||
|
||||
# ── 5. Run pytest ────────────────────────────────────────────────────────────
|
||||
|
||||
log "Running pytest suite in $CALDAV_DIR/"
|
||||
export OXICLOUD_CALDAV_URL="$base_url/caldav/"
|
||||
export OXICLOUD_CALDAV_USERNAME="$username"
|
||||
export OXICLOUD_CALDAV_APP_PASSWORD="$APP_PASSWORD"
|
||||
|
||||
cd "$CALDAV_DIR"
|
||||
# `--show-capture=no` hides pytest's "Captured log setup/call" section
|
||||
# entirely on failure. pycaldav emits a full lxml XMLSyntaxError
|
||||
# traceback via `logging.critical(..., exc_info=True)` on every
|
||||
# make_calendar() when the server ignores the URL slug — genuine
|
||||
# assertion output was drowning in it. Real test failures still show
|
||||
# the assertion line + short traceback via --tb=short.
|
||||
#
|
||||
# Don't let a pytest non-zero exit skip the cleanup trap — capture
|
||||
# the status, invoke cleanup (which tails the server log on failure),
|
||||
# then re-emit the exit code.
|
||||
set +e
|
||||
pytest -v --tb=short --show-capture=no "$@"
|
||||
SUITE_EXIT=$?
|
||||
set -e
|
||||
|
||||
if [[ $SUITE_EXIT -eq 0 ]]; then
|
||||
log "pycaldav suite passed."
|
||||
else
|
||||
log "pycaldav suite failed (exit $SUITE_EXIT)."
|
||||
fi
|
||||
# Always show where the server log is — useful for post-mortem
|
||||
# ("why did the server log an error next to that XFAIL?") even
|
||||
# on green runs. On failure the cleanup trap has already dumped
|
||||
# the tail; the file itself sticks around until the next run
|
||||
# truncates it.
|
||||
log "Server log preserved at: $SERVER_LOG"
|
||||
exit "$SUITE_EXIT"
|
||||
Reference in New Issue
Block a user