265 lines
12 KiB
Bash
Executable File
265 lines
12 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Bundled-binary integration test.
|
|
#
|
|
# Builds `oxicloud` with `--features bundled-assets`, then boots it
|
|
# with the on-disk `static-dist/` moved aside and OXICLOUD_STATIC_PATH
|
|
# pointed at a nonexistent directory — the ONLY code path this can
|
|
# take is the embedded corpus. Then curls the SPA shell, a locale
|
|
# file, and a deep-link route to prove the embed serves correctly.
|
|
#
|
|
# Why this test exists: the bundled-assets feature has three failure
|
|
# modes that don't surface in normal filesystem-served CI:
|
|
#
|
|
# 1. rust-embed configuration bugs (glob patterns, `include`/`exclude`
|
|
# attrs). A wrong glob can silently produce a 0-file embed —
|
|
# caught 2026-08-28.
|
|
# 2. Debug-vs-release behaviour drift. rust-embed's dynamic-read mode
|
|
# in debug builds reads from disk at runtime, which masks embed
|
|
# bugs. `debug-embed` feature bakes files in for BOTH profiles
|
|
# (this test relies on it).
|
|
# 3. Axum `Path` extractor on fallback routes returning 500. The
|
|
# embedded `serve_root` handler needs `Request` extraction, not
|
|
# `Path` — caught 2026-08-28.
|
|
#
|
|
# All three are boot / first-request bugs a normal integration suite
|
|
# would miss. See docs/plan/bundled-binary.md § Verification.
|
|
#
|
|
# Usage (from repo root):
|
|
# bash tests/bundled-binary/run.sh
|
|
#
|
|
# Prerequisites: docker, cargo, node+npm (for the frontend build), curl
|
|
|
|
set -euo pipefail
|
|
|
|
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
|
COMMON="$REPO_ROOT/tests/common"
|
|
TEST_DIR="$REPO_ROOT/tests/bundled-binary"
|
|
|
|
# shellcheck source=test.env
|
|
source "$TEST_DIR/test.env"
|
|
SERVER_PORT="${base_url##*:}"
|
|
|
|
log() { echo "[bundled-binary] $*"; }
|
|
die() { echo "[bundled-binary] ERROR: $*" >&2; exit 1; }
|
|
pass() { echo "[bundled-binary] ✓ $*"; }
|
|
fail() { echo "[bundled-binary] ✗ $*" >&2; FAILS=$((FAILS + 1)); }
|
|
|
|
wait_for_http() {
|
|
local url="$1" timeout="${2:-120}"
|
|
local deadline=$(( $(date +%s) + timeout ))
|
|
until curl -sf "$url" >/dev/null 2>&1; do
|
|
[[ $(date +%s) -ge $deadline ]] && die "Timeout waiting for $url"
|
|
sleep 1
|
|
done
|
|
}
|
|
|
|
# ── Cleanup state ────────────────────────────────────────────────────
|
|
#
|
|
# The trap kills the running server (a stray port-8090 process would
|
|
# collide with the next run) and tears down the test postgres. We do
|
|
# NOT touch `static-dist/` — the embed path is forced via
|
|
# `OXICLOUD_STATIC_PATH` alone (see step 5), so there's no filesystem
|
|
# state to restore.
|
|
SERVER_PID=""
|
|
FAILS=0
|
|
|
|
cleanup() {
|
|
local rc=$?
|
|
if [[ -n "$SERVER_PID" ]]; then
|
|
log "Stopping server (pid $SERVER_PID)..."
|
|
kill "$SERVER_PID" 2>/dev/null || true
|
|
wait "$SERVER_PID" 2>/dev/null || true
|
|
fi
|
|
bash "$COMMON/stop-db.sh" 2>/dev/null || true
|
|
exit "$rc"
|
|
}
|
|
trap cleanup EXIT
|
|
|
|
# ── 1. Ensure static-dist/ is present at build time ─────────────────
|
|
#
|
|
# rust-embed's derive macro scans this directory at compile time. If
|
|
# it's missing, build.rs's `bundled_assets_guard` panics. This step
|
|
# builds the SPA in-place when the dir is absent.
|
|
#
|
|
# `SKIP_FRONTEND_BUILD=1` opts out and fails fast with a hint — useful
|
|
# in CI pipelines where the SPA build is a separate cached step
|
|
# upstream of this test.
|
|
if [[ ! -f "$REPO_ROOT/static-dist/index.html" ]]; then
|
|
if [[ "${SKIP_FRONTEND_BUILD:-0}" == "1" ]]; then
|
|
die "static-dist/index.html missing (SKIP_FRONTEND_BUILD=1). \
|
|
Build the SPA first: (cd frontend && npm ci && npm run build)"
|
|
fi
|
|
log "static-dist/ missing — building the SPA (set SKIP_FRONTEND_BUILD=1 to opt out)..."
|
|
if [[ ! -d "$REPO_ROOT/frontend/node_modules" ]]; then
|
|
log " running 'npm ci' (first-time install)..."
|
|
(cd "$REPO_ROOT/frontend" && npm ci) || die "npm ci failed"
|
|
fi
|
|
(cd "$REPO_ROOT/frontend" && npm run build) || die "npm run build failed"
|
|
[[ -f "$REPO_ROOT/static-dist/index.html" ]] || die "SPA build finished but static-dist/index.html still missing"
|
|
fi
|
|
|
|
# ── 2. Build with --features bundled-assets ──────────────────────────
|
|
#
|
|
# Debug build — matches what a dev iterates on locally, faster than
|
|
# --release, and the `debug-embed` feature in rust-embed makes debug
|
|
# and release behave identically here (both compile-time embed).
|
|
log "Building oxicloud with --features bundled-assets..."
|
|
(cd "$REPO_ROOT" && cargo build --features bundled-assets --bin oxicloud 2>&1 | tail -n 5) \
|
|
|| die "cargo build --features bundled-assets failed"
|
|
OXICLOUD_BIN="$REPO_ROOT/target/debug/oxicloud"
|
|
[[ -x "$OXICLOUD_BIN" ]] || die "Binary missing after build: $OXICLOUD_BIN"
|
|
|
|
# ── 3. Start test Postgres ───────────────────────────────────────────
|
|
log "Starting test Postgres via $COMMON/spawn-db.sh..."
|
|
bash "$COMMON/spawn-db.sh"
|
|
|
|
# ── 4. Boot the server with the embed forced ─────────────────────────
|
|
#
|
|
# `OXICLOUD_STATIC_PATH=/tmp/oxicloud-bundled-nonexistent-$$` is a path
|
|
# that provably doesn't exist (unique to this run's PID). The resolver
|
|
# in `resolve_static_source` runs two filesystem probes derived from
|
|
# this env var, BOTH of which miss:
|
|
# 1. `<parent>/static-dist/` → `/tmp/static-dist/` (vanishingly
|
|
# unlikely to exist)
|
|
# 2. `OXICLOUD_STATIC_PATH` itself — nonexistent by construction
|
|
# The resolver then falls through to `StaticSource::Embedded`. Repo-root
|
|
# `static-dist/` is never consulted at runtime — parenthood is derived
|
|
# from the env var, not CWD — so this test is stateless on the working
|
|
# directory.
|
|
STORAGE="$TEST_DIR/storage"
|
|
rm -rf "$STORAGE" && mkdir -p "$STORAGE"
|
|
|
|
LOG_FILE="$TEST_DIR/server.log"
|
|
: > "$LOG_FILE"
|
|
|
|
set -a
|
|
# shellcheck source=../common/server.env
|
|
source "$COMMON/server.env"
|
|
OXICLOUD_SERVER_PORT=$SERVER_PORT
|
|
OXICLOUD_STORAGE_PATH="$STORAGE"
|
|
OXICLOUD_STATIC_PATH="/tmp/oxicloud-bundled-nonexistent-$$"
|
|
# Disable the Prometheus /metrics listener — the shared server.env pins
|
|
# it to 127.0.0.1:9090 which collides when another test server (or a
|
|
# stray dev process) already holds that port, killing our boot before
|
|
# /ready is reachable. Metrics aren't part of what this test asserts.
|
|
OXICLOUD_METRICS_LISTEN=""
|
|
# Override the shared server.env's `RUST_LOG=warn,audit=info,...` which
|
|
# suppresses the info-level `static-assets:` lines this test asserts on
|
|
# (embed-source resolution + locale extraction). Match the app's own
|
|
# default (main.rs::run) so `http=warn` still tames the access log.
|
|
RUST_LOG="info,http=warn,http::web=error"
|
|
set +a
|
|
|
|
log "Starting server on port $SERVER_PORT with embed forced..."
|
|
"$OXICLOUD_BIN" > "$LOG_FILE" 2>&1 &
|
|
SERVER_PID=$!
|
|
wait_for_http "$base_url/ready" 120
|
|
log "Server ready — running assertions."
|
|
|
|
# ── 6. Assertions ────────────────────────────────────────────────────
|
|
|
|
# 6a. Boot log confirms the resolver picked the embed path (not
|
|
# silently fell back to a stale filesystem dir).
|
|
if grep -q 'static-assets: no filesystem source found, serving embedded corpus' "$LOG_FILE"; then
|
|
pass "boot log: resolver picked StaticSource::Embedded"
|
|
else
|
|
fail "boot log missing 'serving embedded corpus' line — was a filesystem source unexpectedly found?"
|
|
fi
|
|
|
|
# 6b. Boot log confirms N>0 locales were staged. This is the guard
|
|
# against the 2026-08-28 glob bug — a silent 0 would look like a
|
|
# "success" to a non-strict test.
|
|
if grep -Eq 'staged [1-9][0-9]* embedded locale file\(s\)' "$LOG_FILE"; then
|
|
staged=$(grep -oE 'staged [0-9]+ embedded locale' "$LOG_FILE" | tail -n1 | awk '{print $2}')
|
|
pass "boot log: staged $staged embedded locale file(s)"
|
|
else
|
|
fail "boot log: staged 0 locales (or line missing) ← the 2026-08-28 regression class"
|
|
fi
|
|
|
|
# 6c. SPA shell reachable at /
|
|
code=$(curl -s -o /dev/null -w '%{http_code}' "$base_url/")
|
|
if [[ "$code" == "200" ]]; then
|
|
pass "GET / → 200"
|
|
else
|
|
fail "GET / → $code (expected 200)"
|
|
fi
|
|
|
|
# 6d. Shell body looks like the SvelteKit-built index.html. Any of
|
|
# `<!doctype html>` or `data-color-scheme` or `<meta http-equiv=
|
|
# "content-security-policy"` would confirm it's the real shell
|
|
# and not an error page.
|
|
body=$(curl -s "$base_url/")
|
|
if echo "$body" | grep -qi '<!doctype html>' && echo "$body" | grep -q 'data-color-scheme'; then
|
|
pass "SPA shell body has SvelteKit markers (<!doctype html> + data-color-scheme)"
|
|
else
|
|
fail "SPA shell body doesn't look like the real index.html"
|
|
fi
|
|
|
|
# 6e. SPA fallback handler serves the shell for deep-link routes.
|
|
# `serve_root` MUST NOT return 500 here (the 2026-08-28 axum
|
|
# `Path` extractor bug on fallback routes).
|
|
for path in /login /files/some-deep-link; do
|
|
code=$(curl -s -o /dev/null -w '%{http_code}' "$base_url$path")
|
|
if [[ "$code" == "200" ]]; then
|
|
pass "GET $path → 200 (SPA fallback)"
|
|
else
|
|
fail "GET $path → $code (SPA fallback broken?)"
|
|
fi
|
|
done
|
|
|
|
# 6f. Favicon served from the embed (specific bytes, not the shell).
|
|
code=$(curl -s -o /dev/null -w '%{http_code}' "$base_url/favicon.ico")
|
|
ct=$(curl -sI "$base_url/favicon.ico" | grep -i '^content-type:' | tr -d '\r' | awk '{print $2}')
|
|
if [[ "$code" == "200" ]] && [[ "$ct" != text/html* ]]; then
|
|
pass "GET /favicon.ico → 200 with non-HTML content-type ($ct)"
|
|
else
|
|
fail "GET /favicon.ico → code=$code content-type=$ct (should be 200 image/*)"
|
|
fi
|
|
|
|
# 6g. Locale JSON reachable AND is valid JSON with expected shape.
|
|
locale_body=$(curl -sf "$base_url/locales/en.json" || echo '')
|
|
if echo "$locale_body" | head -c 1 | grep -q '{'; then
|
|
pass "GET /locales/en.json → JSON body"
|
|
else
|
|
fail "GET /locales/en.json didn't return a JSON body"
|
|
fi
|
|
|
|
# 6h. Immutable-asset cache header is applied by the `_app/immutable`
|
|
# nested router. Pick any hashed asset from the embed inventory
|
|
# — the boot log doesn't list them, so grep the shell HTML for one
|
|
# of its `modulepreload` refs.
|
|
imm_asset=$(echo "$body" | grep -oE '/_app/immutable/[^"]+\.js' | head -n1)
|
|
if [[ -n "$imm_asset" ]]; then
|
|
cc=$(curl -sI "$base_url$imm_asset" | grep -i '^cache-control:' | tr -d '\r')
|
|
if echo "$cc" | grep -q 'immutable'; then
|
|
pass "immutable-asset cache header applied: $cc"
|
|
else
|
|
fail "immutable-asset $imm_asset cache header wrong: $cc"
|
|
fi
|
|
else
|
|
fail "couldn't find a /_app/immutable/*.js reference in the shell HTML to test"
|
|
fi
|
|
|
|
# 6i. Shell HTML carries a Content-Security-Policy with sha256 script
|
|
# hashes. The SvelteKit build inlines a <meta http-equiv= ...> in
|
|
# index.html; that alone counts (belt). If the axum response-level
|
|
# CSP also carries hashes, even better (suspenders) — but the
|
|
# current middleware ships a hardcoded string, so we only check
|
|
# the meta tag which comes from the embedded bytes.
|
|
if echo "$body" | grep -q "'sha256-"; then
|
|
pass "SPA shell HTML carries CSP with sha256 script hashes (from Vite build)"
|
|
else
|
|
fail "SPA shell HTML has no sha256 CSP hashes — build produced a shell without them?"
|
|
fi
|
|
|
|
# ── Report ───────────────────────────────────────────────────────────
|
|
|
|
echo ""
|
|
if [[ $FAILS -gt 0 ]]; then
|
|
echo "─── SERVER LOG (last 60 lines) ───────────────────────────"
|
|
tail -n 60 "$LOG_FILE"
|
|
echo "──────────────────────────────────────────────────────────"
|
|
die "$FAILS assertion(s) failed"
|
|
fi
|
|
log "bundled-binary integration tests passed ✅"
|