#!/usr/bin/env bash # ============================================================================= # smoke-edge-subpath.sh — End-to-end subpath routing smoke test # # Verifies Forgejo, Woodpecker, and chat function correctly under subpaths: # - Forgejo at /forge/ # - Woodpecker at /ci/ # - Chat at /chat/ # - Staging at /staging/ # # Acceptance criteria: # 1. Forgejo login at /forge/ completes without redirect loops # 2. Forgejo OAuth callback for Woodpecker succeeds under subpath # 3. Woodpecker dashboard loads all assets at /ci/ (no 404s on JS/CSS) # 4. Chat OAuth login flow works at /chat/login # 5. Forward_auth on /chat/* rejects unauthenticated requests with 401 # 6. Staging content loads at /staging/ # 7. Root / redirects to /forge/ # # Usage: # smoke-edge-subpath.sh [--base-url BASE_URL] # # Environment variables: # BASE_URL — Edge proxy URL (default: http://localhost) # EDGE_TIMEOUT — Request timeout in seconds (default: 30) # EDGE_MAX_RETRIES — Max retries per request (default: 3) # # Exit codes: # 0 — All checks passed # 1 — One or more checks failed # ============================================================================= set -euo pipefail # Script directory for relative paths SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # Source common helpers source "${SCRIPT_DIR}/../lib/env.sh" 2>/dev/null || true # ───────────────────────────────────────────────────────────────────────────── # Configuration # ───────────────────────────────────────────────────────────────────────────── BASE_URL="${BASE_URL:-http://localhost}" EDGE_TIMEOUT="${EDGE_TIMEOUT:-30}" EDGE_MAX_RETRIES="${EDGE_MAX_RETRIES:-3}" # Subpaths to test FORGE_PATH="/forge/" CI_PATH="/ci/" CHAT_PATH="/chat/" STAGING_PATH="/staging/" # Track overall test status FAILED=0 PASSED=0 SKIPPED=0 # ───────────────────────────────────────────────────────────────────────────── # Logging helpers # ───────────────────────────────────────────────────────────────────────────── log_info() { echo "[INFO] $*" } log_pass() { echo "[PASS] $*" ((PASSED++)) || true } log_fail() { echo "[FAIL] $*" ((FAILED++)) || true } log_skip() { echo "[SKIP] $*" ((SKIPPED++)) || true } log_section() { echo "" echo "=== $* ===" echo "" } # ───────────────────────────────────────────────────────────────────────────── # HTTP helpers # ───────────────────────────────────────────────────────────────────────────── # Make an HTTP request with retry logic # Usage: http_request [options...] # Returns: HTTP status code on stdout, body on stderr http_request() { local method="$1" local url="$2" shift 2 local retries=0 local response status while [ "$retries" -lt "$EDGE_MAX_RETRIES" ]; do response=$(curl -sS -w '\n%{http_code}' -X "$method" \ --max-time "$EDGE_TIMEOUT" \ -o /tmp/edge-response-$$ \ "$@" "$url" 2>&1) || { retries=$((retries + 1)) log_info "Retry $retries/$EDGE_MAX_RETRIES for $url" sleep 1 continue } status=$(echo "$response" | tail -n1) echo "$status" return 0 done log_fail "Max retries exceeded for $url" return 1 } # Make a GET request and return status code # Usage: http_get [curl_options...] # Returns: HTTP status code http_get() { local url="$1" shift http_request "GET" "$url" "$@" } # Make a HEAD request (no body) # Usage: http_head [curl_options...] # Returns: HTTP status code http_head() { local url="$1" shift http_request "HEAD" "$url" "$@" } # ───────────────────────────────────────────────────────────────────────────── # Test checkers # ───────────────────────────────────────────────────────────────────────────── # Check if a URL returns a valid response (2xx or 3xx) # Usage: check_http_status check_http_status() { local url="$1" local expected_pattern="$2" local description="$3" local status status=$(http_get "$url") if echo "$status" | grep -qE "$expected_pattern"; then log_pass "$description: $url → $status" return 0 else log_fail "$description: $url → $status (expected: $expected_pattern)" return 1 fi } # Check that a URL does NOT redirect in a loop # Usage: check_no_redirect_loop [max_redirects] check_no_redirect_loop() { local url="$1" local max_redirects="${2:-10}" local description="$3" # Use curl with max redirects and check the final status local response status follow_location response=$(curl -sS -w '\n%{http_code}\n%{redirect_url}' \ --max-time "$EDGE_TIMEOUT" \ --max-redirs "$max_redirects" \ -o /tmp/edge-response-$$ \ "$url" 2>&1) || { log_fail "$description: curl failed ($?)" return 1 } status=$(echo "$response" | sed -n '$p') follow_location=$(echo "$response" | sed -n "$((NR-1))p") # If we hit max redirects, the last redirect is still in follow_location if [ "$status" = "000" ] && [ -n "$follow_location" ]; then log_fail "$description: possible redirect loop detected (last location: $follow_location)" return 1 fi # Check final status is in valid range if echo "$status" | grep -qE '^(2|3)[0-9][0-9]$'; then log_pass "$description: no redirect loop ($status)" return 0 else log_fail "$description: unexpected status $status" return 1 fi } # Check that specific assets load without 404 # Usage: check_assets_no_404 check_assets_no_404() { local base_url="$1" local _pattern="$2" local description="$3" local assets_found=0 local assets_404=0 # Fetch the main page and extract asset URLs local main_page main_page=$(curl -sS --max-time "$EDGE_TIMEOUT" "$base_url" 2>/dev/null) || { log_skip "$description: could not fetch main page" return 0 } # Extract URLs matching the pattern (e.g., .js, .css files) local assets assets=$(echo "$main_page" | grep -oE 'https?://[^"'"'"']+\.(js|css|woff|woff2|ttf|eot|svg|png|jpg|jpeg|gif|ico)' | sort -u || true) if [ -z "$assets" ]; then log_skip "$description: no assets found to check" return 0 fi assets_found=$(echo "$assets" | wc -l) # Check each asset while IFS= read -r asset; do local status status=$(http_head "$asset") if [ "$status" = "404" ]; then log_fail "$description: asset 404: $asset" assets_404=$((assets_404 + 1)) fi done <<< "$assets" if [ $assets_404 -eq 0 ]; then log_pass "$description: all $assets_found assets loaded (0 404s)" return 0 else log_fail "$description: $assets_404/$assets_found assets returned 404" return 1 fi } # Check that a path returns 401 (unauthorized) # Usage: check_returns_401 check_returns_401() { local url="$1" local description="$2" local status status=$(http_get "$url") if [ "$status" = "401" ]; then log_pass "$description: $url → 401 (as expected)" return 0 else log_fail "$description: $url → $status (expected 401)" return 1 fi } # Check that a path returns 302 redirect to expected location # Usage: check_redirects_to check_redirects_to() { local url="$1" local expected_target="$2" local description="$3" local response status location response=$(curl -sS -w '\n%{http_code}\n%{redirect_url}' \ --max-time "$EDGE_TIMEOUT" \ --max-redirs 1 \ -o /tmp/edge-response-$$ \ "$url" 2>&1) || { log_fail "$description: curl failed" return 1 } status=$(echo "$response" | sed -n '$p') location=$(echo "$response" | sed -n "$((NR-1))p") if [ "$status" = "302" ] && echo "$location" | grep -qF "$expected_target"; then log_pass "$description: redirects to $location" return 0 else log_fail "$description: status=$status, location=$location (expected 302 → $expected_target)" return 1 fi } # ───────────────────────────────────────────────────────────────────────────── # Main test suite # ───────────────────────────────────────────────────────────────────────────── main() { log_section "Edge Subpath Routing Smoke Test" log_info "Base URL: $BASE_URL" log_info "Timeout: ${EDGE_TIMEOUT}s, Max retries: $EDGE_MAX_RETRIES" # ─── Test 1: Root redirects to /forge/ ────────────────────────────────── log_section "Test 1: Root redirects to /forge/" check_redirects_to "$BASE_URL" "$FORGE_PATH" "Root redirect" || FAILED=1 if [ "$FAILED" -eq 0 ]; then ((PASSED++)) || true; fi # ─── Test 2: Forgejo login at /forge/ without redirect loops ──────────── log_section "Test 2: Forgejo login at /forge/" check_no_redirect_loop "$BASE_URL$FORGE_PATH" 10 "Forgejo root" || FAILED=1 check_http_status "$BASE_URL$FORGE_PATH" "^(2|3)[0-9][0-9]$" "Forgejo root status" || FAILED=1 if [ "$FAILED" -eq 0 ]; then ((PASSED++)) || true; fi # ─── Test 3: Forgejo OAuth callback at /forge/_oauth/callback ─────────── log_section "Test 3: Forgejo OAuth callback at /forge/_oauth/callback" check_http_status "$BASE_URL/forge/_oauth/callback" "^(2|3|4|5)[0-9][0-9]$" "Forgejo OAuth callback" || FAILED=1 if [ "$FAILED" -eq 0 ]; then ((PASSED++)) || true; fi # ─── Test 4: Woodpecker dashboard at /ci/ ─────────────────────────────── log_section "Test 4: Woodpecker dashboard at /ci/" check_no_redirect_loop "$BASE_URL$CI_PATH" 10 "Woodpecker root" || FAILED=1 check_http_status "$BASE_URL$CI_PATH" "^(2|3)[0-9][0-9]$" "Woodpecker root status" || FAILED=1 check_assets_no_404 "$BASE_URL$CI_PATH" "\.(js|css)" "Woodpecker assets" || FAILED=1 if [ "$FAILED" -eq 0 ]; then ((PASSED++)) || true; fi # ─── Test 5: Chat OAuth login at /chat/login ──────────────────────────── log_section "Test 5: Chat OAuth login at /chat/login" check_http_status "$BASE_URL$CHAT_PATH/login" "^(2|3)[0-9][0-9]$" "Chat login page" || FAILED=1 if [ "$FAILED" -eq 0 ]; then ((PASSED++)) || true; fi # ─── Test 6: Chat OAuth callback at /chat/oauth/callback ──────────────── log_section "Test 6: Chat OAuth callback at /chat/oauth/callback" check_http_status "$BASE_URL/chat/oauth/callback" "^(2|3)[0-9][0-9]$" "Chat OAuth callback" || FAILED=1 if [ "$FAILED" -eq 0 ]; then ((PASSED++)) || true; fi # ─── Test 7: Forward_auth on /chat/* returns 401 for unauthenticated ──── log_section "Test 7: Forward_auth on /chat/* returns 401" # Test a protected chat endpoint (chat dashboard) check_returns_401 "$BASE_URL$CHAT_PATH/" "Chat root (unauthenticated)" || FAILED=1 check_returns_401 "$BASE_URL$CHAT_PATH/dashboard" "Chat dashboard (unauthenticated)" || FAILED=1 if [ "$FAILED" -eq 0 ]; then ((PASSED++)) || true; fi # ─── Test 8: Staging at /staging/ ─────────────────────────────────────── log_section "Test 8: Staging at /staging/" check_http_status "$BASE_URL$STAGING_PATH" "^(2|3)[0-9][0-9]$" "Staging root" || FAILED=1 if [ "$FAILED" -eq 0 ]; then ((PASSED++)) || true; fi # ─── Test 9: Caddy admin API health ───────────────────────────────────── log_section "Test 9: Caddy admin API health" # Caddy admin API is typically on port 2019 locally if curl -sS --max-time 5 "http://127.0.0.1:2019/" >/dev/null 2>&1; then log_pass "Caddy admin API reachable" ((PASSED++)) else log_skip "Caddy admin API not reachable (expected if edge is remote)" fi # ─── Summary ──────────────────────────────────────────────────────────── log_section "Test Summary" log_info "Passed: $PASSED" log_info "Failed: $FAILED" log_info "Skipped: $SKIPPED" if [ $FAILED -gt 0 ]; then log_section "TEST FAILED" exit 1 fi log_section "TEST PASSED" exit 0 } # Run main main "$@"