diff --git a/Cargo.toml b/Cargo.toml index 39a3893..e7223c8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,7 @@ rusqlite = { version = "0.41.0", features = ["bundled"] } serde = { version = "1.0.229", features = ["derive"] } serde_json = "1.0.154" tokio = { version = "1.54.1", features = ["macros", "rt-multi-thread"] } -tower-http = { version = "0.7.1", features = ["compression-gzip"] } +tower-http = { version = "0.7.1", features = ["compression-gzip", "cors"] } tracing = "0.1.44" tracing-subscriber = "0.3.23" uuid = { version = "1.29.0", features = ["v4", "serde"] } diff --git a/repl-test.sh b/repl-test.sh index 522ee08..3ca029d 100755 --- a/repl-test.sh +++ b/repl-test.sh @@ -1,44 +1,84 @@ #!/usr/bin/env bash -# Starts a primary (:3000) and a replica (:3001), writes a few records to the -# primary and checks that they show up on the replica. -# Run from the project root: bash repl-test.sh -# Override the binary with: BIN=/path/to/rust_test bash repl-test.sh +# ============================================================================== +# repl-test.sh: Integration harness for Records API & Replication +# Starts Primary (:3000), Replica (:3001), and Dashboard (:8080). +# ============================================================================== set -euo pipefail -BIN="${BIN:-$(pwd)/target/debug/rust_test}" -WORK="${WORK:-$(pwd)/repl-test}" -TOKEN="${TOKEN:-dev-secret-token}" -PRIMARY_PORT=3000 -REPLICA_PORT=3001 +# --- Configuration & Overrides --- +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BIN="${BIN:-$ROOT_DIR/target/debug/rust_test}" +WORK="${WORK:-$ROOT_DIR/repl-test}" +PRIMARY_PORT="${PRIMARY_PORT:-3000}" +REPLICA_PORT="${REPLICA_PORT:-3001}" +DASHBOARD_PORT="${DASHBOARD_PORT:-8080}" +TOKEN="${TOKEN:-change-me}" # Matches index.html default +CORS_ORIGIN="${CORS_ORIGIN:-http://127.0.0.1:$DASHBOARD_PORT}" +START_DASHBOARD="${START_DASHBOARD:-1}" -[[ -x "$BIN" ]] || { echo "Binary not found or not executable: $BIN" >&2; exit 1; } +PIDS=() -# Each instance gets its own directory: messages.db is opened relative to cwd. +# --- Helper Utilities --- +check_port() { + local port=$1 + if command -v lsof >/dev/null 2>&1; then + lsof -iTCP:"$port" -sTCP:LISTEN -n -P >/dev/null 2>&1 + elif command -v nc >/dev/null 2>&1; then + nc -z 127.0.0.1 "$port" >/dev/null 2>&1 + else + (exec 3<>/dev/tcp/127.0.0.1/"$port") 2>/dev/null && { exec 3>&-; exec 3<&-; return 0; } || return 1 + fi +} + +cleanup() { + trap - EXIT INT TERM + echo -e "\n\033[1;33mStopping all instances and dashboard...\033[0m" + for pid in "${PIDS[@]}"; do + if kill -0 "$pid" 2>/dev/null; then + kill "$pid" 2>/dev/null || true + fi + done + wait 2>/dev/null || true + echo "Cleaned up." +} +trap cleanup EXIT INT TERM + +# --- Pre-flight Checks --- +for port in "$PRIMARY_PORT" "$REPLICA_PORT"; do + if check_port "$port"; then + echo "Error: Port $port is already in use by another process." >&2 + exit 1 + fi +done + +if [[ ! -x "$BIN" ]]; then + if [[ -f "$ROOT_DIR/Cargo.toml" ]]; then + echo "Binary not found at $BIN. Building debug binary with cargo..." + ( cd "$ROOT_DIR" && cargo build ) + else + echo "Error: Binary not found or not executable: $BIN" >&2 + exit 1 + fi +fi + +# --- Workspace & Schema Setup --- rm -rf "$WORK" mkdir -p "$WORK/primary" "$WORK/replica" +# Schema allows "msg" and "message", requiring integer "version" +# This aligns with index.html tests and manual curl inputs. for d in primary replica; do cat > "$WORK/$d/config.json" <<'EOF' { "mode": "loose", "fields": { - "msg": { "type": "string" } + "version": { "type": "integer" } } } EOF done -PIDS=() -cleanup() { - trap - EXIT INT TERM - echo - echo "Stopping instances..." - for pid in "${PIDS[@]}"; do kill "$pid" 2>/dev/null || true; done - wait 2>/dev/null || true -} -trap cleanup EXIT INT TERM - -start() { # name port extra-env... +start_node() { local name=$1 port=$2 shift 2 ( cd "$WORK/$name" && exec env \ @@ -46,80 +86,100 @@ start() { # name port extra-env... APP_BIND="127.0.0.1:$port" \ APP_REPL_TOKEN="$TOKEN" \ APP_SYNC_INTERVAL_SECS=1 \ + APP_CORS_ORIGIN="$CORS_ORIGIN" \ "$@" "$BIN" ) >"$WORK/$name/log.txt" 2>&1 & PIDS+=("$!") } -wait_up() { # port +wait_up() { + local name=$1 port=$2 for _ in $(seq 1 50); do - curl -sf "http://127.0.0.1:$1/api/now" >/dev/null 2>&1 && return 0 + if curl -sf "http://127.0.0.1:$port/api/now" >/dev/null 2>&1; then + return 0 + fi sleep 0.1 done - echo "Instance on port $1 did not come up; see logs in $WORK" >&2 + echo -e "\n\033[1;31mError: $name failed to start on :$port\033[0m" >&2 + if [[ -f "$WORK/$name/log.txt" ]]; then + echo "--- Last 20 lines of $WORK/$name/log.txt ---" >&2 + tail -n 20 "$WORK/$name/log.txt" >&2 + fi exit 1 } -echo "Starting primary on :$PRIMARY_PORT" -start primary "$PRIMARY_PORT" APP_ROLE=primary -wait_up "$PRIMARY_PORT" +# --- Launch Cluster --- +echo -e "\033[1;32m==> Starting Primary on :$PRIMARY_PORT\033[0m" +start_node primary "$PRIMARY_PORT" APP_ROLE=primary +wait_up "primary" "$PRIMARY_PORT" -echo "Starting replica on :$REPLICA_PORT" -start replica "$REPLICA_PORT" \ +echo -e "\033[1;32m==> Starting Replica on :$REPLICA_PORT\033[0m" +start_node replica "$REPLICA_PORT" \ APP_ROLE=replica \ APP_PRIMARY_URL="http://127.0.0.1:$PRIMARY_PORT" -wait_up "$REPLICA_PORT" +wait_up "replica" "$REPLICA_PORT" -echo -echo "Writing 3 records to the primary" +# --- Launch Dashboard (if available) --- +if [[ "$START_DASHBOARD" -eq 1 && -f "$ROOT_DIR/dashboard.py" ]]; then + if check_port "$DASHBOARD_PORT"; then + echo -e "\033[1;33mDashboard port :$DASHBOARD_PORT is in use. Skipping dashboard.\033[0m" + else + echo -e "\033[1;32m==> Starting Dashboard on :$DASHBOARD_PORT\033[0m" + ( cd "$ROOT_DIR" && exec python3 dashboard.py ) >"$WORK/dashboard.log" 2>&1 & + PIDS+=("$!") + fi +fi + +# --- Write Smoke Test Records --- +echo -e "\n\033[1;34m==> Writing 3 test records to Primary\033[0m" for i in 1 2 3; do curl -s -X POST "http://127.0.0.1:$PRIMARY_PORT/api/records" \ -H 'Content-Type: application/json' \ - -d "{\"data\":{\"msg\":\"hello $i\"}}" - echo + -d "{\"data\":{\"version\":1,\"msg\":\"hello $i\"}}" >/dev/null + echo " Record #$i written" done -echo -echo "Waiting for the replica to catch up..." +# --- Await Replication Sync --- +echo -e "\n\033[1;34m==> Verifying replica sync...\033[0m" caught_up=0 -for _ in $(seq 1 30); do - if curl -s "http://127.0.0.1:$REPLICA_PORT/api/replication/status" \ - | grep -q '"last_applied_seq":3'; then +for _ in $(seq 1 40); do + status_output=$(curl -s "http://127.0.0.1:$REPLICA_PORT/api/replication/status" || true) + if echo "$status_output" | grep -q '"last_applied_seq":3'; then caught_up=1 break fi - sleep 0.5 + sleep 0.25 done -echo -echo "Replica status:" -curl -s "http://127.0.0.1:$REPLICA_PORT/api/replication/status" -echo -echo -echo "Replica records:" -curl -s "http://127.0.0.1:$REPLICA_PORT/api/records" -echo -echo -echo "Write to the replica (should be rejected):" -curl -s -w ' [HTTP %{http_code}]\n' -X POST "http://127.0.0.1:$REPLICA_PORT/api/records" \ - -H 'Content-Type: application/json' \ - -d '{"data":{"msg":"nope"}}' - -echo if [[ $caught_up -eq 1 ]]; then - echo "OK: replica caught up." + echo -e "\033[1;32m✔ SUCCESS: Replica caught up to seq 3!\033[0m" else - echo "WARNING: replica did not reach seq 3 in time; check $WORK/replica/log.txt" + echo -e "\033[1;31m✖ WARNING: Replica did not reach seq 3 in time.\033[0m" fi +# --- Verify Read-Only Guard on Replica --- +echo -e "\n\033[1;34m==> Testing Replica read-only safety guard:\033[0m" +http_code=$(curl -s -o /dev/null -w "%{http_code}" -X POST "http://127.0.0.1:$REPLICA_PORT/api/records" \ + -H 'Content-Type: application/json' \ + -d '{"data":{"version":1,"msg":"should fail"}}') + +if [[ "$http_code" == "403" ]]; then + echo -e " \033[1;32m✔ HTTP 403 Forbidden received as expected.\033[0m" +else + echo -e " \033[1;31m✖ Expected HTTP 403, got $http_code.\033[0m" +fi + +# --- Ready Banner --- cat < { + info!(origin = %origin, "CORS enabled"); + app.layer( + tower_http::cors::CorsLayer::new() + .allow_origin( + origin + .parse::() + .expect("invalid APP_CORS_ORIGIN"), + ) + .allow_methods([Method::GET, Method::POST, Method::OPTIONS]) + .allow_headers([CONTENT_TYPE, AUTHORIZATION]), + ) + } + Err(_) => app, + }; + let addr = std::env::var("APP_BIND").unwrap_or_else(|_| "127.0.0.1:3000".into()); let listener = tokio::net::TcpListener::bind(&addr) .await