fix: vision(#623): end-to-end subpath routing smoke test for Forgejo + Woodpecker + chat (#1025)

This commit is contained in:
dev-qwen2 2026-04-19 15:58:45 +00:00
parent 9cc12f2303
commit dc80081ba1
3 changed files with 901 additions and 0 deletions

390
tests/smoke-edge-subpath.sh Executable file
View file

@ -0,0 +1,390 @@
#!/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 <method> <url> [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-$$ \
"$@" 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 <url> [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 <url> [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 <url> <expected_pattern>
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 <url> [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 <base_url> <pattern>
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 <url> <description>
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 <url> <expected_target> <description>
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 "$@"

168
tests/test-caddyfile-routing.sh Executable file
View file

@ -0,0 +1,168 @@
#!/usr/bin/env bash
# =============================================================================
# test-caddyfile-routing.sh — Unit test for Caddyfile routing block shape
#
# Verifies that the edge.hcl Nomad job template contains correctly configured
# routing blocks for all subpaths:
# - /forge/ — Forgejo subpath
# - /ci/ — Woodpecker subpath
# - /staging/ — Staging subpath
# - /chat/ — Chat subpath with forward_auth
#
# Usage:
# test-caddyfile-routing.sh [--template PATH]
#
# Environment variables:
# EDGE_TEMPLATE — Path to edge.hcl template (default: nomad/jobs/edge.hcl)
#
# 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)"
PROJECT_ROOT="${SCRIPT_DIR}/.."
# Configuration
EDGE_TEMPLATE="${EDGE_TEMPLATE:-${PROJECT_ROOT}/nomad/jobs/edge.hcl}"
# Track test status
FAILED=0
PASSED=0
# ─────────────────────────────────────────────────────────────────────────────
# Logging helpers
# ─────────────────────────────────────────────────────────────────────────────
log_info() {
echo "[INFO] $*"
}
log_pass() {
echo "[PASS] $*"
((PASSED++)) || true
}
log_fail() {
echo "[FAIL] $*"
((FAILED++)) || true
}
log_section() {
echo ""
echo "=== $* ==="
echo ""
}
# ─────────────────────────────────────────────────────────────────────────────
# Test helpers
# ─────────────────────────────────────────────────────────────────────────────
# Extract the Caddyfile template from edge.hcl
# The template is embedded in a Nomad template stanza with <<EOT heredoc
extract_caddyfile() {
local template_file="$1"
# Extract content between "data = <<" and "EOT" markers
# This handles the Nomad template heredoc syntax
sed -n '/data[[:space:]]*=[[:space:]]*<<[Ee][Oo][Tt]/,/^EOT$/p' "$template_file" | \
sed '1s/.*/# Caddyfile extracted from Nomad template/; $d'
}
# Check that a pattern exists in the Caddyfile
check_pattern() {
local pattern="$1"
local description="$2"
local caddyfile="$3"
if echo "$caddyfile" | grep -q "$pattern"; then
log_pass "$description"
return 0
else
log_fail "$description"
return 1
fi
}
# ─────────────────────────────────────────────────────────────────────────────
# Main test suite
# ─────────────────────────────────────────────────────────────────────────────
main() {
log_section "Caddyfile Routing Block Unit Test"
log_info "Template file: $EDGE_TEMPLATE"
if [ ! -f "$EDGE_TEMPLATE" ]; then
log_fail "Template file not found: $EDGE_TEMPLATE"
exit 1
fi
# Extract the Caddyfile template
CADDYFILE=$(extract_caddyfile "$EDGE_TEMPLATE")
if [ -z "$CADDYFILE" ]; then
log_fail "Could not extract Caddyfile template from $EDGE_TEMPLATE"
exit 1
fi
log_info "Caddyfile template extracted successfully"
# ─── Test 1: Forgejo subpath ────────────────────────────────────────────
log_section "Test 1: Forgejo subpath (/forge/)"
check_pattern "handle /forge/\*" "Forgejo handle block" "$CADDYFILE"
check_pattern "reverse_proxy 127.0.0.1:3000" "Forgejo reverse_proxy (port 3000)" "$CADDYFILE"
# ─── Test 2: Woodpecker subpath ─────────────────────────────────────────
log_section "Test 2: Woodpecker subpath (/ci/)"
check_pattern "handle /ci/\*" "Woodpecker handle block" "$CADDYFILE"
check_pattern "reverse_proxy 127.0.0.1:8000" "Woodpecker reverse_proxy (port 8000)" "$CADDYFILE"
# ─── Test 3: Staging subpath ────────────────────────────────────────────
log_section "Test 3: Staging subpath (/staging/)"
check_pattern "handle /staging/\*" "Staging handle block" "$CADDYFILE"
# Staging uses Nomad service discovery, so check for the template syntax
check_pattern "nomadService" "Staging Nomad service discovery" "$CADDYFILE"
# ─── Test 4: Chat subpath ───────────────────────────────────────────────
log_section "Test 4: Chat subpath (/chat/)"
check_pattern "handle /chat/login" "Chat login handle block" "$CADDYFILE"
check_pattern "handle /chat/oauth/callback" "Chat OAuth callback handle block" "$CADDYFILE"
check_pattern "handle /chat/\*" "Chat catch-all handle block" "$CADDYFILE"
check_pattern "reverse_proxy 127.0.0.1:8080" "Chat reverse_proxy (port 8080)" "$CADDYFILE"
# ─── Test 5: Forward auth for chat ──────────────────────────────────────
log_section "Test 5: Forward auth configuration"
# Check that forward_auth block exists for /chat/*
if echo "$CADDYFILE" | grep -A10 "handle /chat/\*" | grep -q "forward_auth"; then
log_pass "forward_auth block found for /chat/*"
else
log_fail "forward_auth block missing for /chat/*"
fi
# ─── Test 6: Root redirect ──────────────────────────────────────────────
log_section "Test 6: Root redirect"
check_pattern "redir /forge/ 302" "Root redirect to /forge/" "$CADDYFILE"
# ─── Summary ────────────────────────────────────────────────────────────
log_section "Test Summary"
log_info "Passed: $PASSED"
log_info "Failed: $FAILED"
if [ $FAILED -gt 0 ]; then
log_section "TEST FAILED"
exit 1
fi
log_section "TEST PASSED"
exit 0
}
# Run main
main "$@"