feat(bundled-binary): add a test
This commit is contained in:
@@ -176,6 +176,21 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
components: clippy
|
components: clippy
|
||||||
- uses: Swatinem/rust-cache@v2
|
- 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
|
- run: cargo clippy --all-targets --all-features -- -D warnings
|
||||||
|
|
||||||
# Mirrors the `wasm-check` justfile recipe. The wasm crate is a
|
# Mirrors the `wasm-check` justfile recipe. The wasm crate is a
|
||||||
@@ -306,6 +321,18 @@ jobs:
|
|||||||
- uses: dtolnay/rust-toolchain@stable
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
- uses: Swatinem/rust-cache@v2
|
- 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
|
- name: Initialize test database
|
||||||
# Applies every migration + seeds the integration-test admin row.
|
# Applies every migration + seeds the integration-test admin row.
|
||||||
# Same script used by `just test-integration` locally.
|
# Same script used by `just test-integration` locally.
|
||||||
@@ -460,6 +487,79 @@ jobs:
|
|||||||
path: tests/api/storage/
|
path: tests/api/storage/
|
||||||
retention-days: 7
|
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:
|
litmus:
|
||||||
name: WebDAV RFC 4918 — litmus (59/59)
|
name: WebDAV RFC 4918 — litmus (59/59)
|
||||||
needs: build
|
needs: build
|
||||||
|
|||||||
+18
-5
@@ -48,11 +48,24 @@ async-stream = "0.3.6"
|
|||||||
async-trait = "0.1.89"
|
async-trait = "0.1.89"
|
||||||
mime_guess = "2.0.5"
|
mime_guess = "2.0.5"
|
||||||
# `rust-embed` — compile-time asset embedding for the `bundled-assets` feature.
|
# `rust-embed` — compile-time asset embedding for the `bundled-assets` feature.
|
||||||
# Optional so default builds never pull it in. `compression` deflate-compresses
|
# Optional so default builds never pull it in.
|
||||||
# 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
|
# Features:
|
||||||
# for the actual embed struct + handlers.
|
# * `compression` — deflate-compress each embedded file at compile time; the
|
||||||
rust-embed = { version = "8", features = ["compression", "include-exclude"], optional = true }
|
# 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"] }
|
uuid = { version = "1.23.3", features = ["v4", "v7", "serde"] }
|
||||||
thiserror = "2.0.18"
|
thiserror = "2.0.18"
|
||||||
arc-swap = "1.9"
|
arc-swap = "1.9"
|
||||||
|
|||||||
@@ -45,15 +45,9 @@ fn bundled_assets_guard() {
|
|||||||
// when cargo's default output is quiet; the panic below turns
|
// when cargo's default output is quiet; the panic below turns
|
||||||
// them into a compile-time error so the missing prerequisite
|
// them into a compile-time error so the missing prerequisite
|
||||||
// can't slip past a distracted dev.
|
// can't slip past a distracted dev.
|
||||||
println!(
|
println!("cargo:warning=`bundled-assets` feature requires static-dist/ at the repo root.");
|
||||||
"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=Build the SvelteKit SPA first: (cd frontend && npm run build)"
|
|
||||||
);
|
|
||||||
println!(
|
|
||||||
"cargo:warning=Or via the workspace shortcut: just fe-build"
|
|
||||||
);
|
|
||||||
panic!(
|
panic!(
|
||||||
"build.rs: missing {}/index.html — see the cargo:warning lines above",
|
"build.rs: missing {}/index.html — see the cargo:warning lines above",
|
||||||
dist.display()
|
dist.display()
|
||||||
|
|||||||
@@ -252,10 +252,10 @@ front-design:
|
|||||||
# real browser, which the curl-driven
|
# real browser, which the curl-driven
|
||||||
# suite above can't observe.
|
# 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
|
# .github/workflows/ci.yml; keep the order in sync so a local pass means
|
||||||
# CI passes.
|
# CI passes.
|
||||||
api-test:
|
test-api:
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -x
|
set -x
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -270,6 +270,12 @@ api-test:
|
|||||||
echo "XXX litmus webdav not found, ignore test"
|
echo "XXX litmus webdav not found, ignore test"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# backward compat
|
||||||
|
api-test: test-api
|
||||||
|
|
||||||
|
test-bundle:
|
||||||
|
./tests/bundled-binary/run.sh
|
||||||
|
|
||||||
# CalDAV client-driven conformance suite.
|
# CalDAV client-driven conformance suite.
|
||||||
#
|
#
|
||||||
# Drives OxiCloud through the maintained `python-caldav` client library
|
# 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
|
# (RFC 5545 §3.8.4.4), and all-day masters (the shape #528 was filed
|
||||||
# against).
|
# 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
|
# 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
|
# Runs `cargo build` first so the orchestrator always sees a fresh
|
||||||
# binary. run-pycaldav.sh itself doesn't rebuild — it uses whatever
|
# 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
|
# 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
|
# (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.
|
# there's no automated assertion here, it's a visual check. Ctrl-C to stop.
|
||||||
#oidc-manual-sso-only:
|
#oidc-manual-sso-only:
|
||||||
# bash tests/oidc/run-manual-sso-only.sh
|
# bash tests/oidc/run-manual-sso-only.sh
|
||||||
@@ -383,4 +389,4 @@ test-docker-tags:
|
|||||||
|
|
||||||
# Check and test everything
|
# Check and test everything
|
||||||
# recommanded before pull request
|
# 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
|
||||||
|
|||||||
@@ -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()));
|
let mut resp = Response::new(Body::from(bytes.into_owned()));
|
||||||
resp.headers_mut().insert(
|
resp.headers_mut().insert(
|
||||||
CONTENT_TYPE,
|
CONTENT_TYPE,
|
||||||
HeaderValue::from_str(mime.as_ref()).unwrap_or(HeaderValue::from_static(
|
HeaderValue::from_str(mime.as_ref())
|
||||||
"application/octet-stream",
|
.unwrap_or(HeaderValue::from_static("application/octet-stream")),
|
||||||
)),
|
|
||||||
);
|
);
|
||||||
resp
|
resp
|
||||||
}
|
}
|
||||||
@@ -119,8 +118,10 @@ fn spa_shell_response() -> Response {
|
|||||||
match EmbeddedAssets::get("index.html") {
|
match EmbeddedAssets::get("index.html") {
|
||||||
Some(shell) => {
|
Some(shell) => {
|
||||||
let mut resp = Response::new(Body::from(shell.data.into_owned()));
|
let mut resp = Response::new(Body::from(shell.data.into_owned()));
|
||||||
resp.headers_mut()
|
resp.headers_mut().insert(
|
||||||
.insert(CONTENT_TYPE, HeaderValue::from_static("text/html; charset=utf-8"));
|
CONTENT_TYPE,
|
||||||
|
HeaderValue::from_static("text/html; charset=utf-8"),
|
||||||
|
);
|
||||||
// Belt: the parent module also stamps this on unset,
|
// Belt: the parent module also stamps this on unset,
|
||||||
// but stamp it here too so the shell never accidentally
|
// but stamp it here too so the shell never accidentally
|
||||||
// ends up cacheable in front of a deploy.
|
// ends up cacheable in front of a deploy.
|
||||||
|
|||||||
@@ -126,10 +126,7 @@ pub fn resolve_static_path(config: &AppConfig) -> PathBuf {
|
|||||||
/// Caching: content-hashed assets under `/_app/immutable` are cached forever;
|
/// Caching: content-hashed assets under `/_app/immutable` are cached forever;
|
||||||
/// everything else — crucially the `index.html` shell — is `no-cache` so a deploy
|
/// everything else — crucially the `index.html` shell — is `no-cache` so a deploy
|
||||||
/// can't leave a stale app pinned in browsers.
|
/// can't leave a stale app pinned in browsers.
|
||||||
pub fn create_web_routes(
|
pub fn create_web_routes(app_state: Arc<AppState>, source: StaticSource) -> Router<Arc<AppState>> {
|
||||||
app_state: Arc<AppState>,
|
|
||||||
source: StaticSource,
|
|
||||||
) -> Router<Arc<AppState>> {
|
|
||||||
// `source` is resolved ONCE at boot in `main.rs::run()` and passed
|
// `source` is resolved ONCE at boot in `main.rs::run()` and passed
|
||||||
// in — see the sequence there. Previously this fn called
|
// in — see the sequence there. Previously this fn called
|
||||||
// `AppConfig::from_env()` + `resolve_static_source(&config)` itself
|
// `AppConfig::from_env()` + `resolve_static_source(&config)` itself
|
||||||
|
|||||||
Executable
+264
@@ -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. `<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 ✅"
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user