From f692dd23e49e4fbb2cc99aca8b60c4c3a4d3a956 Mon Sep 17 00:00:00 2001 From: dev-qwen2 Date: Sun, 19 Apr 2026 04:53:01 +0000 Subject: [PATCH] fix: vision(#623): end-to-end subpath routing smoke test for Forgejo + Woodpecker + chat (#1025) --- .woodpecker/edge-subpath.yml | 55 +++++ tests/smoke-edge-subpath.sh | 390 +++++++++++++++++++++++++++++++++++ 2 files changed, 445 insertions(+) create mode 100644 .woodpecker/edge-subpath.yml create mode 100755 tests/smoke-edge-subpath.sh diff --git a/.woodpecker/edge-subpath.yml b/.woodpecker/edge-subpath.yml new file mode 100644 index 0000000..6e0a17e --- /dev/null +++ b/.woodpecker/edge-subpath.yml @@ -0,0 +1,55 @@ +# .woodpecker/edge-subpath.yml — Edge subpath routing smoke test +# +# Runs end-to-end smoke tests for Forgejo, Woodpecker, and chat subpath routing: +# - Forgejo at /forge/ +# - Woodpecker at /ci/ +# - Chat at /chat/ +# - Staging at /staging/ +# +# Tests: +# 1. Root / redirects to /forge/ +# 2. Forgejo login at /forge/ completes without redirect loops +# 3. Forgejo OAuth callback for Woodpecker succeeds under subpath +# 4. Woodpecker dashboard loads all assets at /ci/ (no 404s on JS/CSS) +# 5. Chat OAuth login flow works at /chat/login +# 6. Forward_auth on /chat/* rejects unauthenticated requests with 401 +# 7. Staging content loads at /staging/ +# +# Triggers: +# - Pull requests that modify edge-related files +# - Manual trigger for on-demand testing +# +# Environment variables (set in CI or via pipeline): +# EDGE_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) +# +# When to run: +# - Any change to edge.hcl, docker/edge/, tools/edge-control/ +# - Any change to this pipeline file +# - Manual trigger for testing edge deployments + +when: + event: [pull_request, manual] + path: + - "nomad/jobs/edge.hcl" + - "docker/edge/**" + - "tools/edge-control/**" + - ".woodpecker/edge-subpath.yml" + - "tests/smoke-edge-subpath.sh" + +clone: + git: + image: alpine/git + commands: + - AUTH_URL=$(printf '%s' "$CI_REPO_CLONE_URL" | sed "s|://|://token:$FORGE_TOKEN@|") + - git clone --depth 1 "$AUTH_URL" . + - git fetch --depth 1 origin "$CI_COMMIT_REF" + - git checkout FETCH_HEAD + +steps: + - name: edge-subpath-smoke-test + image: alpine:3.19 + commands: + - apk add --no-cache bash curl jq + - bash tests/smoke-edge-subpath.sh diff --git a/tests/smoke-edge-subpath.sh b/tests/smoke-edge-subpath.sh new file mode 100755 index 0000000..d23d06b --- /dev/null +++ b/tests/smoke-edge-subpath.sh @@ -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 [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 "$@"