Files
Oxicloud/tests/caldav/run-pycaldav.sh
T

224 lines
9.1 KiB
Bash
Raw Normal View History

2026-07-14 20:34:19 +02:00
#!/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"
2026-07-15 00:31:22 +02:00
# Use the binary if it's already there — CI downloads a pre-built
# release artifact and would waste ~5 min recompiling from scratch
# (empty target cache) if we always rebuilt. Local devs get the
# fresh-binary guarantee via `just test-caldav`, which runs
# `cargo build` before invoking this script (see the recipe in
# justfile).
#
# The stale-binary trap this used to guard against (a `cargo check`
# or `cargo clippy` leaving the on-disk binary behind while source
# changed) only bites when this script is invoked DIRECTLY without
# going through the justfile — a rare workflow. Documented on
# `just test-caldav` for the record.
if [[ ! -x "$OXICLOUD_BIN" ]]; then
log "Building OxiCloud ($BUILD_TARGET) — no pre-built binary at $OXICLOUD_BIN..."
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"
else
log "Using pre-built OxiCloud at $OXICLOUD_BIN ($BUILD_TARGET)"
fi
2026-07-14 20:34:19 +02:00
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"