diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b880195c..7a19b1ea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -176,6 +176,21 @@ jobs: with: components: clippy - uses: Swatinem/rust-cache@v2 + + # `--all-features` includes `bundled-assets`, whose build.rs guard + # requires `static-dist/index.html` at compile time (rust-embed + # scans the folder). Build the SPA first so the lint pass covers + # the embed code paths without needing to enumerate features + # around it. ~90 s once, cached by npm-cache on repeats. + - uses: actions/setup-node@v4 + with: + node-version: 26.3.0 + cache: npm + cache-dependency-path: frontend/package-lock.json + - name: Build SPA (needed for --all-features / bundled-assets) + working-directory: frontend + run: npm ci && npm run build + - run: cargo clippy --all-targets --all-features -- -D warnings # Mirrors the `wasm-check` justfile recipe. The wasm crate is a @@ -306,6 +321,18 @@ jobs: - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 + # `--all-features` enables `bundled-assets`, whose build.rs guard + # requires `static-dist/index.html` at compile time. Build the SPA + # first so tests can compile the embed code paths. ~90 s, cached. + - uses: actions/setup-node@v4 + with: + node-version: 26.3.0 + cache: npm + cache-dependency-path: frontend/package-lock.json + - name: Build SPA (needed for --all-features / bundled-assets) + working-directory: frontend + run: npm ci && npm run build + - name: Initialize test database # Applies every migration + seeds the integration-test admin row. # Same script used by `just test-integration` locally. @@ -460,6 +487,79 @@ jobs: path: tests/api/storage/ retention-days: 7 + bundled-binary-test: + # `--features bundled-assets` end-to-end integration test. + # + # Builds oxicloud with the SPA baked in via rust-embed, boots it + # against a nonexistent OXICLOUD_STATIC_PATH so the embed path is + # forced, and asserts SPA + locales + immutable-cache headers + + # CSP all serve correctly from the embedded corpus. Guards against + # three failure classes that don't surface in filesystem-served CI: + # + # 1. rust-embed configuration (glob patterns silently producing a + # 0-file embed — hit 2026-08-28). + # 2. Debug-vs-release drift (rust-embed's dynamic-read mode in + # debug builds masks embed bugs; `debug-embed` feature bakes + # files in for both profiles). + # 3. Axum `Path` extractor on fallback routes returning 500 (the + # `serve_root` handler needs `Request` extraction — hit 2026-08-28). + # + # See tests/bundled-binary/run.sh + docs/plan/bundled-binary.md § 2. + # + # Doesn't reuse the `build` job's artifact because that binary is + # compiled with `--features plugins`, not `--features bundled-assets` + # — different feature set = different target. `Swatinem/rust-cache` + # still shares dependency compilation between the two jobs. + name: Bundled-assets binary — embed + SPA-serve integration + needs: changes + if: | + github.event_name == 'pull_request' && + (needs.changes.outputs.backend == 'true' || needs.changes.outputs.frontend == 'true') + timeout-minutes: 30 + runs-on: ubuntu-latest + steps: + # Same disk-hygiene pattern as the `build` job — cargo release + # link + full node_modules install would otherwise squeeze the + # runner disk budget under peak concurrency. + - uses: jlumbroso/free-disk-space@main + with: + tool-cache: false + android: true + dotnet: true + haskell: true + large-packages: false + + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - uses: actions/setup-node@v4 + with: + node-version: 26.3.0 + cache: npm + cache-dependency-path: frontend/package-lock.json + + # Build the SPA up front so the test script can run with + # `SKIP_FRONTEND_BUILD=1` — makes the test-runner log clean + # (no duplicated npm ci noise) and puts the SPA build's cost + # in its own step for CI-side timing visibility. + - name: Build SPA (Vite → static-dist/) + working-directory: frontend + run: npm ci && npm run build + + - name: Run bundled-binary integration test + run: bash tests/bundled-binary/run.sh + env: + SKIP_FRONTEND_BUILD: "1" + + # Preserve the server log even on failure so a red run doesn't + # require re-running locally to see what happened at boot. + - uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: bundled-binary-server-log + path: tests/bundled-binary/server.log + retention-days: 7 + litmus: name: WebDAV RFC 4918 — litmus (59/59) needs: build diff --git a/Cargo.toml b/Cargo.toml index cafd5186..aacf31cf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,11 +48,24 @@ async-stream = "0.3.6" async-trait = "0.1.89" mime_guess = "2.0.5" # `rust-embed` — compile-time asset embedding for the `bundled-assets` feature. -# Optional so default builds never pull it in. `compression` deflate-compresses -# each embedded file at compile time; the handler decompresses lazily on first -# read (cached per-file in a `OnceCell`). See src/interfaces/web/embedded.rs -# for the actual embed struct + handlers. -rust-embed = { version = "8", features = ["compression", "include-exclude"], optional = true } +# Optional so default builds never pull it in. +# +# Features: +# * `compression` — deflate-compress each embedded file at compile time; the +# handler decompresses lazily on first read (cached per-file in a +# `OnceCell`). Halves the on-disk contribution to the final binary. +# * `include-exclude` — enables the `#[include]` / `#[exclude]` glob +# attributes on the derive. +# * `debug-embed` — CRITICAL. Without this, debug builds read files from +# disk at runtime (dynamic mode) instead of compiling them in. The +# runtime read is fragile (`CARGO_MANIFEST_DIR` resolution + working +# directory dependency) and returned `total=0` for us on 2026-08-28. +# With `debug-embed`, both `cargo build` and `cargo build --release` +# produce a truly self-contained binary — the only sensible default +# for the `bundled-assets` feature. +# +# See src/interfaces/web/embedded.rs for the actual embed struct + handlers. +rust-embed = { version = "8", features = ["compression", "include-exclude", "debug-embed"], optional = true } uuid = { version = "1.23.3", features = ["v4", "v7", "serde"] } thiserror = "2.0.18" arc-swap = "1.9" diff --git a/build.rs b/build.rs index 78c3f0d9..28ce7298 100644 --- a/build.rs +++ b/build.rs @@ -45,15 +45,9 @@ fn bundled_assets_guard() { // when cargo's default output is quiet; the panic below turns // them into a compile-time error so the missing prerequisite // can't slip past a distracted dev. - println!( - "cargo:warning=`bundled-assets` feature requires static-dist/ at the repo root." - ); - println!( - "cargo:warning=Build the SvelteKit SPA first: (cd frontend && npm run build)" - ); - println!( - "cargo:warning=Or via the workspace shortcut: just fe-build" - ); + println!("cargo:warning=`bundled-assets` feature requires static-dist/ at the repo root."); + println!("cargo:warning=Build the SvelteKit SPA first: (cd frontend && npm run build)"); + println!("cargo:warning=Or via the workspace shortcut: just fe-build"); panic!( "build.rs: missing {}/index.html — see the cargo:warning lines above", dist.display() diff --git a/justfile b/justfile index c51a91b0..fe306d35 100644 --- a/justfile +++ b/justfile @@ -252,10 +252,10 @@ front-design: # real browser, which the curl-driven # suite above can't observe. # -# Same chain runs in CI under the `api-test` job in +# Same chain runs in CI under the `test-api` job in # .github/workflows/ci.yml; keep the order in sync so a local pass means # CI passes. -api-test: +test-api: #!/usr/bin/env bash set -x set -euo pipefail @@ -270,6 +270,12 @@ api-test: echo "XXX litmus webdav not found, ignore test" fi +# backward compat +api-test: test-api + +test-bundle: + ./tests/bundled-binary/run.sh + # CalDAV client-driven conformance suite. # # Drives OxiCloud through the maintained `python-caldav` client library @@ -279,9 +285,9 @@ api-test: # (RFC 5545 §3.8.4.4), and all-day masters (the shape #528 was filed # against). # -# Not chained into `api-test` because it needs python3; run explicitly. +# Not chained into `test-api` because it needs python3; run explicitly. # The orchestrator spawns its own postgres + server on port 8091 so it -# can run in parallel with api-test/webdav. +# can run in parallel with test-api/webdav. # # Runs `cargo build` first so the orchestrator always sees a fresh # binary. run-pycaldav.sh itself doesn't rebuild — it uses whatever @@ -301,7 +307,7 @@ test-caldav: # Manual, human-run: launches OxiCloud with OIDC as the ONLY login method # (fake IdP on :1081, server on :8090) and waits for you to eyeball the -# /login auto-redirect in a real browser. Not part of `just api-test` — +# /login auto-redirect in a real browser. Not part of `just test-api` — # there's no automated assertion here, it's a visual check. Ctrl-C to stop. #oidc-manual-sso-only: # bash tests/oidc/run-manual-sso-only.sh @@ -383,4 +389,4 @@ test-docker-tags: # Check and test everything # recommanded before pull request -pre-pull-request: test-docker-tags check fe-check audit check-migrations test test-integration fe-test build api-test fe-build-e2e front-test +pre-pull-request: test-docker-tags check fe-check audit check-migrations test test-integration fe-test build test-bundle test-api fe-build-e2e front-test diff --git a/src/interfaces/web/embedded.rs b/src/interfaces/web/embedded.rs index b027eebe..398c7687 100644 --- a/src/interfaces/web/embedded.rs +++ b/src/interfaces/web/embedded.rs @@ -108,9 +108,8 @@ fn asset_response(path: &str, bytes: std::borrow::Cow<'static, [u8]>) -> Respons let mut resp = Response::new(Body::from(bytes.into_owned())); resp.headers_mut().insert( CONTENT_TYPE, - HeaderValue::from_str(mime.as_ref()).unwrap_or(HeaderValue::from_static( - "application/octet-stream", - )), + HeaderValue::from_str(mime.as_ref()) + .unwrap_or(HeaderValue::from_static("application/octet-stream")), ); resp } @@ -119,8 +118,10 @@ fn spa_shell_response() -> Response { match EmbeddedAssets::get("index.html") { Some(shell) => { let mut resp = Response::new(Body::from(shell.data.into_owned())); - resp.headers_mut() - .insert(CONTENT_TYPE, HeaderValue::from_static("text/html; charset=utf-8")); + resp.headers_mut().insert( + CONTENT_TYPE, + HeaderValue::from_static("text/html; charset=utf-8"), + ); // Belt: the parent module also stamps this on unset, // but stamp it here too so the shell never accidentally // ends up cacheable in front of a deploy. diff --git a/src/interfaces/web/mod.rs b/src/interfaces/web/mod.rs index caa165a7..0f99647f 100644 --- a/src/interfaces/web/mod.rs +++ b/src/interfaces/web/mod.rs @@ -126,10 +126,7 @@ pub fn resolve_static_path(config: &AppConfig) -> PathBuf { /// Caching: content-hashed assets under `/_app/immutable` are cached forever; /// everything else — crucially the `index.html` shell — is `no-cache` so a deploy /// can't leave a stale app pinned in browsers. -pub fn create_web_routes( - app_state: Arc, - source: StaticSource, -) -> Router> { +pub fn create_web_routes(app_state: Arc, source: StaticSource) -> Router> { // `source` is resolved ONCE at boot in `main.rs::run()` and passed // in — see the sequence there. Previously this fn called // `AppConfig::from_env()` + `resolve_static_source(&config)` itself diff --git a/tests/bundled-binary/run.sh b/tests/bundled-binary/run.sh new file mode 100755 index 00000000..eb7aa18f --- /dev/null +++ b/tests/bundled-binary/run.sh @@ -0,0 +1,264 @@ +#!/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. `/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 +# `` or `data-color-scheme` or `' && echo "$body" | grep -q 'data-color-scheme'; then + pass "SPA shell body has SvelteKit markers ( + 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 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 ✅" diff --git a/tests/bundled-binary/test.env b/tests/bundled-binary/test.env new file mode 100644 index 00000000..a047b5e5 --- /dev/null +++ b/tests/bundled-binary/test.env @@ -0,0 +1,8 @@ +# Bundled-binary integration test — runs the oxicloud server built with +# `--features bundled-assets` on a dedicated port so it doesn't collide +# with the api / webdav / oidc runners in a `just api-test` chain. +# +# Only variables the run.sh consumes as bash vars live here (test port, +# any curl-side seed data). Server-side env is loaded via +# tests/common/server.env inside the runner. +base_url=http://localhost:8090