diff --git a/README.md b/README.md index e296a4d..ff76409 100644 --- a/README.md +++ b/README.md @@ -207,7 +207,80 @@ Passing all checks earns the contributor the **RPC Ready Badge** as part of the bash scripts/test-rpc.sh https://rpc.meechain.live/rpc https://rpc.meechain.live/health ``` -ถ้าอยู่หลัง Cloudflare Access: +## Production post-deploy validation +ใช้หลัง `docker compose up -d` เพื่อตรวจ health endpoint, RPC proxy, network config, Web3 status และ DNS พื้นฐานในคำสั่งเดียว: + +```bash +npm run test:prod -- https://rpc.meechain.live +``` + +หลังเปลี่ยน DNS/CNAME ให้ `rpc.meechain.live` ชี้ไปที่ tunnel ชั่วคราว เช่น `speaker-marshall-stations-antonio.trycloudflare.com` แล้ว ให้ทดสอบผ่านชื่อจริงที่ผู้ใช้จะเรียก (`rpc.meechain.live`) เป็นหลัก. ถ้าเปิด proxy สีส้มของ Cloudflare อยู่ ภายนอกอาจเห็นเป็น IP ของ Cloudflare แทน CNAME target ซึ่งเป็นพฤติกรรมปกติ. + +> หมายเหตุ: URL แบบ `*.trycloudflare.com` จาก quick tunnel จะเปลี่ยนทุกครั้งเมื่อกด Stop/Start หรือ Restart ดังนั้นให้ถือเป็นช่องทางชั่วคราวเท่านั้น และต้องอัปเดต CNAME/เป้าหมาย tunnel ให้ตรงกับ URL ล่าสุดเสมอ. ถ้าต้องการ URL คงที่ในระยะยาว ควรใช้ named Cloudflare Tunnel กับ hostname `rpc.meechain.live` โดยตรง. + +### เปลี่ยนช่องทางไปที่ Named Tunnel `meechain-connect` + +สำหรับ production ให้ใช้ Named Tunnel `meechain-connect` แทน Quick Tunnel เพื่อให้ `rpc.meechain.live` เป็นช่องทางคงที่และไม่เปลี่ยนทุกครั้งที่ restart: + +1. แก้ DNS record `rpc.meechain.live` จาก Quick Tunnel ปัจจุบัน เช่น `exploration-garmin-guided-tower.trycloudflare.com` ไปที่ Named Tunnel target: + + ```text + 74d57437-86f8-47ee-91fa-5260b04a5ba3.cfargotunnel.com + ``` + +2. บนเครื่องที่รัน origin ให้รัน named tunnel แทน quick tunnel: + + ```bash + cloudflared tunnel run meechain-connect + ``` + +3. ตรวจหลัง cutover ผ่าน hostname จริง พร้อมเช็ก CNAME target ของ Named Tunnel: + + ```bash + EXPECTED_CNAME_TARGET=74d57437-86f8-47ee-91fa-5260b04a5ba3.cfargotunnel.com \ + EXPECT_UPSTREAM_CONNECTED=1 \ + npm run test:prod -- https://rpc.meechain.live + ``` + +ถ้า Cloudflare DNS เปิด proxy สีส้มอยู่ CNAME target อาจถูกซ่อนหลัง Cloudflare edge IP ทำให้ CLI ภายนอกเห็นเฉพาะ A/AAAA record; ให้ยืนยัน target ใน Cloudflare dashboard หรือปิด proxy เป็น DNS-only ชั่วคราวตอนตรวจ cutover แล้วค่อยเปิดกลับ. ถ้าต้องการให้ script fail เมื่อมองไม่เห็น CNAME หรือ target ไม่ตรง ให้เพิ่ม `EXPECTED_CNAME_STRICT=1`. + +ถ้าใช้ URL ชั่วคราวจาก `trycloudflare.com` หรือแอป Pocket Live Server ให้ใช้ origin เป็นหลัก เช่น `https://speaker-marshall-stations-antonio.trycloudflare.com` (ไม่ต้องใส่ `/favicon.ico`). ถ้าเผลอวาง URL ที่มี path เช่น `/favicon.ico` สคริปต์จะ normalize กลับไปตรวจที่ origin ให้อัตโนมัติ: + +```bash +npm run test:prod -- https://speaker-marshall-stations-antonio.trycloudflare.com/favicon.ico +``` + +ถ้ามีช่องทางปัจจุบันและช่องทางรองที่ต้องตรวจพร้อมกัน ให้ใช้ `--also` หรือ `PROD_TEST_ADDITIONAL_URLS`: + +```bash +npm run test:prod -- https://rpc.meechain.live --also https://explicitly-browse-placement.trycloudflare.com +PROD_TEST_ADDITIONAL_URLS="https://explicitly-browse-placement.trycloudflare.com,https://molecules-peripheral-accepts-bench.trycloudflare.com" npm run test:prod -- https://rpc.meechain.live +``` + +ถ้าเปิด URL จากแอปแล้วเห็นหน้า MeeChain Dashboard ได้ แปลว่า app tunnel เส้นนั้นกำลังชี้เข้าถึง frontend ได้แล้ว. แต่ถ้าแถบบนขึ้น `RPC upstream ยังไม่พร้อม — ใช้ local/mock data` หรือ `Upstream: Degraded` ให้ตรวจ `/api/web3/status` และ `/rpc/health` เพิ่ม เพราะ frontend online แล้วแต่ upstream RPC อาจยังไม่พร้อม. หากต้องการให้ validation fail ทันทีเมื่อ upstream degraded ให้ตั้ง `EXPECT_UPSTREAM_CONNECTED=1`: + +```bash +EXPECT_UPSTREAM_CONNECTED=1 npm run test:prod -- https://exploration-garmin-guided-tower.trycloudflare.com/. +``` + +ถ้า log ของ `cloudflared` ขึ้น `Unable to reach the origin service ... dial tcp 127.0.0.1:8000: connect: connection refused` ให้ตรวจว่า local service เปิดอยู่ที่ port เดียวกับที่ tunnel ตั้งไว้ก่อนรัน validation เช่น Pocket Live Server เลือก port `8000` ก็ต้องมีแอปรอรับที่ `127.0.0.1:8000`. + +ตรวจผ่าน nginx/Cloudflare tunnel บนเครื่อง production ที่ expose HTTPS แบบ local self-signed certificate ได้ด้วย: + +```bash +npm run test:prod:local +# equivalent: bash scripts/test-production.sh https://localhost:8445 --insecure --skip-network +``` + +ถ้า endpoint อยู่หลัง Cloudflare Access ให้ตั้ง service token ก่อนรัน: + +```bash +export CF_ACCESS_CLIENT_ID="" +export CF_ACCESS_CLIENT_SECRET="" +npm run test:prod -- https://rpc.meechain.live +``` + +สำหรับ RPC smoke test เดิม ถ้าอยู่หลัง Cloudflare Access ให้ใช้ service token ชุดเดียวกัน: ```bash export CF_ACCESS_CLIENT_ID="" export CF_ACCESS_CLIENT_SECRET="" diff --git a/scripts/test-production.sh b/scripts/test-production.sh new file mode 100644 index 0000000..90efd00 --- /dev/null +++ b/scripts/test-production.sh @@ -0,0 +1,444 @@ +#!/usr/bin/env bash +set -euo pipefail + +# usage prints the help message describing the script's command-line arguments, examples, and environment variables. +# usage() แสดงข้อความช่วยเหลือการใช้งาน ตัวอย่างคำสั่ง และตัวแปรสภาพแวดล้อมสำหรับการตรวจสอบการทำงานในสภาพแวดล้อมการผลิต + +usage() { + cat <<'USAGE' +Usage: + bash scripts/test-production.sh [base_url] [--also url] [--insecure] [--skip-app] [--skip-network] [--timeout seconds] + +Examples: + bash scripts/test-production.sh https://rpc.meechain.live + bash scripts/test-production.sh https://rpc.meechain.live --also https://explicitly-browse-placement.trycloudflare.com + bash scripts/test-production.sh https://speaker-marshall-stations-antonio.trycloudflare.com/favicon.ico + bash scripts/test-production.sh https://localhost:8445 --insecure --skip-network + EXPECT_UPSTREAM_CONNECTED=1 bash scripts/test-production.sh https://rpc.meechain.live + EXPECTED_CNAME_TARGET=74d57437-86f8-47ee-91fa-5260b04a5ba3.cfargotunnel.com bash scripts/test-production.sh https://rpc.meechain.live + +Notes: + Pass the public origin that clients use, for example https://rpc.meechain.live. + Temporary trycloudflare.com quick-tunnel URLs rotate after stop/restart, so update + the DNS/CNAME target and any --also URL to the currently active tunnel. + If you paste an asset URL such as /favicon.ico from a tunnel app, the script + automatically normalizes it to the origin before checking endpoints. + Set PROD_TEST_ADDITIONAL_URLS to a comma-separated list to validate standby URLs. + Set EXPECT_UPSTREAM_CONNECTED=1 to fail when /api/web3/status reports degraded. + Set EXPECTED_CNAME_TARGET to verify a named Cloudflare Tunnel CNAME target. + Set EXPECTED_CNAME_STRICT=1 to fail instead of warn when CNAME is hidden/mismatched. +USAGE +} + +BASE_URL="${1:-https://rpc.meechain.live}" +if [[ "$BASE_URL" == --* ]]; then + BASE_URL="https://rpc.meechain.live" +else + shift || true +fi + +TIMEOUT="${PROD_TEST_TIMEOUT:-10}" +EXPECTED_CHAIN_ID_HEX="${EXPECTED_CHAIN_ID_HEX:-0x344e}" +EXPECTED_CHAIN_ID_DEC="${EXPECTED_CHAIN_ID_DEC:-13390}" +EXPECTED_APP_TEXT="${EXPECTED_APP_TEXT:-MeeChain}" +EXPECT_UPSTREAM_CONNECTED="${EXPECT_UPSTREAM_CONNECTED:-0}" +EXPECTED_CNAME_TARGET="${EXPECTED_CNAME_TARGET:-}" +EXPECTED_CNAME_STRICT="${EXPECTED_CNAME_STRICT:-0}" +CF_ACCESS_CLIENT_ID="${CF_ACCESS_CLIENT_ID:-}" +CF_ACCESS_CLIENT_SECRET="${CF_ACCESS_CLIENT_SECRET:-}" +INSECURE=0 +SKIP_NETWORK=0 +SKIP_APP=0 +ADDITIONAL_BASE_URLS=() +if [[ -n "${PROD_TEST_ADDITIONAL_URLS:-}" ]]; then + IFS=',' read -r -a ADDITIONAL_BASE_URLS <<< "$PROD_TEST_ADDITIONAL_URLS" +fi + +while [[ $# -gt 0 ]]; do + case "$1" in + --insecure|-k) + INSECURE=1 + shift + ;; + --skip-network) + SKIP_NETWORK=1 + shift + ;; + --skip-app) + SKIP_APP=1 + shift + ;; + --also) + ADDITIONAL_BASE_URLS+=("${2:?--also requires a URL}") + shift 2 + ;; + --timeout) + TIMEOUT="${2:?--timeout requires a value}" + shift 2 + ;; + --help|-h) + usage + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + exit 2 + ;; + esac +done + +TMP_DIR="$(mktemp -d)" +HEADERS="$TMP_DIR/headers" +BODY="$TMP_DIR/body" +trap 'rm -rf "$TMP_DIR"' EXIT + +# pass prints the provided message prefixed with a checkmark (✅) to stdout. +pass() { echo "✅ $*"; } +# fail prints an error message to stderr and exits the script with code 1. +fail() { echo "❌ $*" >&2; exit 1; } +# warn prints a warning message to stdout with a warning emoji prefix. +warn() { echo "⚠️ $*"; } +# info พิมพ์ข้อความข้อมูลไปยัง stdout พร้อมคำนำหน้า ℹ️ +info() { echo "ℹ️ $*"; } + +# require_cmd ตรวจสอบว่าคำสั่งมีอยู่ใน PATH หากไม่พบจะหยุดสคริปต์ด้วยข้อผิดพลาด +# pass prints a success message to stdout. +pass() { echo "✅ $*"; } +# fail พิมพ์ข้อความข้อผิดพลาดไปยังเอาต์พุตข้อผิดพลาดและออกจากสคริปต์ +fail() { echo "❌ $*" >&2; exit 1; } +# warn prints a warning message with a warning emoji prefix to stdout. +warn() { echo "⚠️ $*"; } +# info แสดงข้อความข้อมูล +info() { echo "ℹ️ $*"; } + +# require_cmd ตรวจสอบว่าคำสั่งที่ระบุมีอยู่ในระบบ หากไม่พบจะออกจากโปรแกรม +require_cmd() { + local cmd="$1" + if ! command -v "$cmd" >/dev/null 2>&1; then + fail "required command not found: $cmd" + fi +} + +# normalize_url แปลง URL ให้เป็น origin (แบบแผน โฮสต์ และพอร์ต) และส่งออกไปยัง stdout หรือล้มเหลวหากกำหนด URL ไม่ถูกต้อง +# normalize_url ทำให้ URL เป็นมาตรฐานโดยแยก origin (scheme + host + port) ออกจากอักษร URL โดยลบเส้นทาง คิวรี และชิ้นส่วน แล้วพิมพ์ origin ไปยัง stdout หากรูปแบบ URL ไม่ถูกต้อง ให้เรียก fail +normalize_url() { + local raw="$1" + local original="${raw%/}" + local normalized + if ! normalized="$(node -e "const u = new URL(process.argv[1]); console.log(u.origin);" "$raw" 2>/dev/null)"; then + fail "invalid base URL: $raw" + fi + if [[ "$normalized" != "$original" ]]; then + info "base URL included a path/query/fragment; using origin: $normalized" >&2 + fi + printf '%s\n' "$normalized" +} + +CURL_ARGS=(-sS -m "$TIMEOUT") +if [[ "$INSECURE" -eq 1 ]]; then + CURL_ARGS+=(-k) +fi + +ACCESS_HEADERS=() +if [[ -n "$CF_ACCESS_CLIENT_ID" && -n "$CF_ACCESS_CLIENT_SECRET" ]]; then + ACCESS_HEADERS+=(-H "CF-Access-Client-Id: ${CF_ACCESS_CLIENT_ID}") + ACCESS_HEADERS+=(-H "CF-Access-Client-Secret: ${CF_ACCESS_CLIENT_SECRET}") +fi + +# status_code extracts and prints the HTTP status code from the response headers file. +# status_code extracts the HTTP status code from the saved headers file and echoes it to stdout. +status_code() { + awk 'toupper($1) ~ /^HTTP/ {code=$2} END{print code}' "$HEADERS" +} + +# assert_no_access_redirect fails if a Cloudflare Access redirect is detected in the HTTP response headers. +# assert_no_access_redirect fails if the HTTP response headers contain a Cloudflare Access redirect. +assert_no_access_redirect() { + local location + location="$(awk 'tolower($1)=="location:" {print $2}' "$HEADERS" | tr -d '\r' || true)" + if [[ "${location:-}" == *"cloudflareaccess.com"* ]]; then + fail "blocked by Cloudflare Access redirect: $location" + fi +} + +# curl_get performs an HTTP GET request to the specified URL and saves the response headers and body for subsequent validation. +# curl_get ส่งคำขอ GET ไปยัง URL และบันทึก headers และ body ของ response ลงในไฟล์ชั่วคราว +curl_get() { + local url="$1" + if ! curl "${CURL_ARGS[@]}" -D "$HEADERS" -o "$BODY" "${ACCESS_HEADERS[@]}" "$url"; then + fail "GET failed for $url" + fi +} + +# curl_post_json posts JSON to the specified URL and saves the response. +# curl_post_json performs an HTTP POST request with JSON content to a specified URL. +curl_post_json() { + local url="$1" + local payload="$2" + if ! curl "${CURL_ARGS[@]}" -D "$HEADERS" -o "$BODY" \ + "${ACCESS_HEADERS[@]}" \ + -H 'content-type: application/json' \ + --data "$payload" \ + "$url"; then + fail "POST JSON failed for $url" + fi +} + +# assert_http_200 validates that the most recent HTTP response has status code 200. +# assert_http_200 validates that the HTTP response has status code 200 and passes Cloudflare Access verification. +assert_http_200() { + local label="$1" + local code + code="$(status_code)" + assert_no_access_redirect + if [[ "$code" != "200" ]]; then + echo "--- response headers ---" >&2 + cat "$HEADERS" >&2 + echo "--- response body ---" >&2 + cat "$BODY" >&2 + fail "$label returned HTTP ${code:-n/a}, expected 200" + fi + pass "$label HTTP 200" +} + +# json_field อ่านข้อมูล JSON จากการตอบสนอง ประเมินนิพจน์ที่ให้มา และแสดงผลลัพธ์ หรือออกจากโปรแกรมด้วยรหัส 3 หากค่าไม่มีหรือเป็น null +# json_field evaluates a JavaScript expression against the parsed JSON in the response body and prints the resolved value, or exits with code 3 if the value is undefined or null. +json_field() { + local expression="$1" + node -e "const fs=require('fs'); const data=JSON.parse(fs.readFileSync(process.argv[1], 'utf8')); const value=($expression); if (value === undefined || value === null) process.exit(3); console.log(typeof value === 'object' ? JSON.stringify(value) : value);" "$BODY" +} + +# assert_json_field_equals ตรวจสอบว่าเขตข้อมูล JSON ของการตอบสนองตรงกับค่าที่คาดหวัง +# assert_json_field_equals ตรวจสอบว่า JSON field มีค่าตรงกับค่าที่คาดไว้ +assert_json_field_equals() { + local label="$1" + local expression="$2" + local expected="$3" + local actual + actual="$(json_field "$expression")" || fail "$label missing or invalid JSON field" + if [[ "$actual" != "$expected" ]]; then + echo "--- response body ---" >&2 + cat "$BODY" >&2 + fail "$label expected '$expected' but got '$actual'" + fi + pass "$label = $actual" +} + +# assert_json_field_present ตรวจสอบว่าช่องข้อมูล JSON ที่ระบุโดยนิพจน์นั้นมีอยู่และถูกต้อง +# assert_json_field_present ตรวจสอบว่านิพจน์ JSON ที่ระบุนั้นมีอยู่และสามารถเข้าถึงได้ +assert_json_field_present() { + local label="$1" + local expression="$2" + json_field "$expression" >/dev/null || fail "$label missing or invalid JSON field" + pass "$label present" +} + +# assert_json_field_truthy_or_warn confirms a JSON field equals true, failing in strict mode (EXPECT_UPSTREAM_CONNECTED=1) or warning in lenient mode with degraded behavior noted. +# assert_json_field_truthy_or_warn validates that a JSON field value is the string 'true', failing if upstream connectivity is required, otherwise warning of degraded state. +assert_json_field_truthy_or_warn() { + local label="$1" + local expression="$2" + local actual + actual="$(json_field "$expression")" || fail "$label missing or invalid JSON field" + if [[ "$actual" == "true" ]]; then + pass "$label = true" + elif [[ "$EXPECT_UPSTREAM_CONNECTED" == "1" ]]; then + echo "--- response body ---" >&2 + cat "$BODY" >&2 + fail "$label expected true but got '$actual'" + else + warn "$label = $actual (degraded; set EXPECT_UPSTREAM_CONNECTED=1 to fail)" + fi +} + +# assert_body_contains ตรวจสอบว่าเนื้อหาการตอบสนองมีข้อความที่กำหนด +# assert_body_contains ตรวจสอบว่าเนื้อหาการตอบสนองประกอบด้วยข้อความที่คาดหวัง และพิมพ์ข้อมูลการวินิจฉัยหากล้มเหลว +assert_body_contains() { + local label="$1" + local expected="$2" + if ! node -e "const fs=require('fs'); const body=fs.readFileSync(process.argv[1], 'utf8'); process.exit(body.includes(process.argv[2]) ? 0 : 1);" "$BODY" "$expected"; then + echo "--- response body preview ---" >&2 + head -c 500 "$BODY" >&2 || true + echo >&2 + fail "$label did not contain expected text: $expected" + fi + pass "$label contains '$expected'" +} + +# check_get_json ส่งคำขอ GET ไปยังจุดปลายทาง API และตรวจสอบว่าการตอบสนอง HTTP มีสถานะ 200 +# check_get_json ส่งคำขอ GET ไปยังเส้นทางและตรวจสอบว่าได้รับการตอบสนอง HTTP 200 +check_get_json() { + local path="$1" + local label="$2" + info "GET ${BASE_URL}${path}" + curl_get "${BASE_URL}${path}" + assert_http_200 "$label" +} + + +# check_app_shell ตรวจสอบว่าเปลือกแอปพลิเคชันมีข้อความที่คาดหวัง +# check_app_shell verifies that the application shell endpoint responds successfully and contains the expected application text. +check_app_shell() { + if [[ "$SKIP_APP" -eq 1 ]]; then + info "skipping app shell check (--skip-app)" + return + fi + + check_get_json "/" "app shell" + assert_body_contains "app shell" "$EXPECTED_APP_TEXT" +} + +# check_health_endpoints validates the health status of root, API, and RPC endpoints and confirms correct chain configuration. +# check_health_endpoints ตรวจสอบเอนดพอยต์สุขภาพและยืนยันว่าสถานะทั้งหมดคืนค่า ok พร้อมฟิลด์ที่คาดหวัง +check_health_endpoints() { + check_get_json "/health" "root health" + assert_json_field_equals "root health status" "data.status" "ok" + assert_json_field_present "root health version" "data.version" + + check_get_json "/api/health" "API health" + assert_json_field_equals "API health status" "data.status" "ok" + + check_get_json "/rpc/health" "RPC health" + assert_json_field_equals "RPC health status" "data.status" "ok" + assert_json_field_equals "RPC health chainId" "String(data.chainId)" "$EXPECTED_CHAIN_ID_DEC" + assert_json_field_present "RPC health mode" "data.mode" +} + +# check_rpc_proxy ตรวจสอบจุดปลายทาง RPC proxy โดยตรวจสอบการตอบสนองรหัสเชนและหมายเลขบล็อก +# check_rpc_proxy ตรวจสอบ RPC proxy endpoint ด้วยการเรียก JSON-RPC eth_chainId และ eth_blockNumber พร้อมยืนยันว่า chain ID ตรงกับที่คาดหวังและ block number มีอยู่ +check_rpc_proxy() { + info "POST ${BASE_URL}/rpc eth_chainId" + curl_post_json "${BASE_URL}/rpc" '{"jsonrpc":"2.0","id":1,"method":"eth_chainId","params":[]}' + assert_http_200 "RPC eth_chainId" + assert_json_field_equals "RPC eth_chainId result" "data.result" "$EXPECTED_CHAIN_ID_HEX" + + info "POST ${BASE_URL}/rpc eth_blockNumber" + curl_post_json "${BASE_URL}/rpc" '{"jsonrpc":"2.0","id":2,"method":"eth_blockNumber","params":[]}' + assert_http_200 "RPC eth_blockNumber" + assert_json_field_present "RPC eth_blockNumber result" "data.result" +} + +# check_network_config ตรวจสอบการกำหนดค่าเครือข่ายและสถานะการเชื่อมต่อ web3 +# check_network_config validates network configuration endpoints for correct chain IDs, RPC URLs, and web3 connectivity. +check_network_config() { + check_get_json "/api/network" "network config" + assert_json_field_equals "network chainId" "String(data.chainId)" "$EXPECTED_CHAIN_ID_HEX" + assert_json_field_equals "network chainIdDecimal" "String(data.chainIdDecimal)" "$EXPECTED_CHAIN_ID_DEC" + assert_json_field_equals "network first rpcUrl" "data.rpcUrls && data.rpcUrls[0]" "${BASE_URL}/rpc" + + check_get_json "/api/web3/status" "web3 status" + assert_json_field_truthy_or_warn "web3 status connected flag" "data.connected" +} + + +# normalize_dns_name removes trailing dots from a DNS name and converts it to lowercase. +# normalize_dns_name removes the trailing dot from a DNS name and converts it to lowercase, echoing the normalized result. +normalize_dns_name() { + local name="$1" + name="${name%.}" + printf '%s\n' "${name,,}" +} + +# check_expected_cname_target validates that a host's CNAME record matches the expected target, failing or warning based on the EXPECTED_CNAME_STRICT setting. +# check_expected_cname_target ตรวจสอบว่า CNAME target ของโฮสต์ตรงกับค่าที่ระบุใน EXPECTED_CNAME_TARGET โดยส่งผลสำเร็จหรือเตือนตามระดับความเข้มงวดของ EXPECTED_CNAME_STRICT +check_expected_cname_target() { + local host="$1" + [[ -n "$EXPECTED_CNAME_TARGET" ]] || return + + local expected + expected="$(normalize_dns_name "$EXPECTED_CNAME_TARGET")" + local targets="" + if command -v dig >/dev/null 2>&1; then + targets="$(dig +short CNAME "$host" 2>/dev/null | sed 's/\.$//' | tr '[:upper:]' '[:lower:]' || true)" + elif command -v nslookup >/dev/null 2>&1; then + targets="$(nslookup -type=CNAME "$host" 2>/dev/null | awk -F'= ' '/canonical name/ {print $2}' | sed 's/\.$//' | tr '[:upper:]' '[:lower:]' || true)" + fi + + if printf '%s\n' "$targets" | awk -v expected="$expected" 'tolower($0) == expected {found=1} END {exit found ? 0 : 1}'; then + pass "CNAME target for $host matches $EXPECTED_CNAME_TARGET" + return + fi + + local message="CNAME target for $host does not visibly match $EXPECTED_CNAME_TARGET" + if [[ -n "$targets" ]]; then + message="$message (observed: $(printf '%s' "$targets" | paste -sd ', ' -))" + else + message="$message (no CNAME visible; Cloudflare orange-cloud proxy can hide the target behind edge A/AAAA records)" + fi + + if [[ "$EXPECTED_CNAME_STRICT" == "1" ]]; then + fail "$message" + fi + warn "$message" +} + +# check_external_network validates DNS resolution for the base URL's hostname and checks its CNAME target against expected configuration. +# check_external_network validates DNS resolution for the hostname in the base URL and checks expected CNAME targets. +check_external_network() { + if [[ "$SKIP_NETWORK" -eq 1 ]]; then + info "skipping external network checks (--skip-network)" + return + fi + + local host + host="$(node -e "const u=new URL(process.argv[1]); console.log(u.hostname)" "$BASE_URL")" + info "DNS lookup for $host" + if command -v getent >/dev/null 2>&1 && getent hosts "$host" >/dev/null 2>&1; then + pass "DNS resolves for $host" + elif command -v nslookup >/dev/null 2>&1 && nslookup "$host" >/dev/null 2>&1; then + pass "DNS resolves for $host" + elif command -v dig >/dev/null 2>&1 && dig +short "$host" | awk 'NF {found=1} END {exit found ? 0 : 1}'; then + pass "DNS resolves for $host" + else + fail "DNS does not resolve for $host" + fi + + check_expected_cname_target "$host" +} + +# run_checks_for_base_url orchestrates production post-deploy validation checks for a MeeChain Connect HTTP endpoint. +# run_checks_for_base_url ดำเนินการตรวจสอบการผลิตสำหรับ URL ฐานที่กำหนด +run_checks_for_base_url() { + BASE_URL="$(normalize_url "$1")" + + echo "== MeeChain production validation ==" + echo "Base URL: $BASE_URL" + echo "Timeout : ${TIMEOUT}s" + if [[ -n "$CF_ACCESS_CLIENT_ID" && -n "$CF_ACCESS_CLIENT_SECRET" ]]; then + info "using Cloudflare Access service token headers" + fi + echo "" + + check_external_network + check_app_shell + check_health_endpoints + check_rpc_proxy + check_network_config + + echo "" + pass "production validation checklist passed for $BASE_URL" +} + +# main orchestrates production post-deployment validation for the primary and additional configured base URLs. +# main validates the production deployment by running health and RPC checks against configured base URLs. +main() { + require_cmd curl + require_cmd node + + local urls=("$BASE_URL") + local extra_url + for extra_url in "${ADDITIONAL_BASE_URLS[@]}"; do + extra_url="${extra_url//[[:space:]]/}" + [[ -n "$extra_url" ]] && urls+=("$extra_url") + done + + local url + for url in "${urls[@]}"; do + run_checks_for_base_url "$url" + done + + echo "" + pass "all production validation checklists passed" +} + +main "$@" diff --git a/test/test-production-script.test.js b/test/test-production-script.test.js new file mode 100644 index 0000000..2a4310a --- /dev/null +++ b/test/test-production-script.test.js @@ -0,0 +1,824 @@ +'use strict'; +/** + * Tests for scripts/test-production.sh (added in PR). + * + * Strategy: + * 1. CLI-level unit tests: exercise argument parsing, flag handling, help + * output, and URL normalisation by running the script with + * child_process.spawnSync and inspecting exit codes / stdout / stderr. + * 2. Integration tests: spin up an in-process Node.js http.createServer mock + * that returns controlled responses for every endpoint the script visits, + * then run the script pointing at localhost: with --skip-network so + * that DNS resolution is bypassed. + * + * The mock server handles: + * GET / → HTML with "MeeChain" + * GET /health → {"status":"ok","version":"1.2.3"} + * GET /api/health → {"status":"ok"} + * GET /rpc/health → {"status":"ok","chainId":13390,"mode":"proxy"} + * POST /rpc → {"result":"0x344e"} (eth_chainId) + * → {"result":"0x100"} (eth_blockNumber) + * GET /api/network → {"chainId":"0x344e","chainIdDecimal":"13390","rpcUrls":["/rpc"]} + * GET /api/web3/status → {"connected":true} + * + * All responses deliberately match the defaults checked by the script so the + * happy-path test passes without any extra env-var overrides. + */ + +const assert = require('assert'); +const { describe, it, before, after } = require('mocha'); +const http = require('http'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +// ── Paths ───────────────────────────────────────────────────────────────────── + +const SCRIPT = path.resolve(__dirname, '..', 'scripts', 'test-production.sh'); + +// ── Helper: run the script synchronously ───────────────────────────────────── + +/** + * Run the production test script synchronously with the given arguments and + * environment overrides. Returns { status, stdout, stderr }. + */ +function runScript(args = [], envOverrides = {}) { + const result = spawnSync('bash', [SCRIPT, ...args], { + encoding: 'utf8', + timeout: 15000, + env: { ...process.env, ...envOverrides }, + }); + return { + status: result.status, + stdout: result.stdout || '', + stderr: result.stderr || '', + }; +} + +// ── Helper: create a mock HTTP server ──────────────────────────────────────── + +/** + * Build the default response map. base is the full origin (http://host:port). + */ +function defaultHandlers(base) { + return { + 'GET /': (req, res) => { + res.writeHead(200, { 'content-type': 'text/html' }); + res.end('MeeChain DashboardMeeChain'); + }, + 'GET /health': (req, res) => { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ status: 'ok', version: '1.2.3' })); + }, + 'GET /api/health': (req, res) => { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ status: 'ok' })); + }, + 'GET /rpc/health': (req, res) => { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ status: 'ok', chainId: 13390, mode: 'proxy' })); + }, + 'POST /rpc': (req, res) => { + let body = ''; + req.on('data', chunk => { body += chunk; }); + req.on('end', () => { + let method = ''; + try { method = JSON.parse(body).method; } catch (_) { /* ignore */ } + res.writeHead(200, { 'content-type': 'application/json' }); + if (method === 'eth_blockNumber') { + res.end(JSON.stringify({ result: '0x100' })); + } else { + // eth_chainId and anything else + res.end(JSON.stringify({ result: '0x344e' })); + } + }); + }, + 'GET /api/network': (req, res) => { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ + chainId: '0x344e', + chainIdDecimal: '13390', + rpcUrls: [`${base}/rpc`], + })); + }, + 'GET /api/web3/status': (req, res) => { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ connected: true })); + }, + }; +} + +/** + * Create and start an HTTP server with a customisable handler map. + * Returns a Promise that resolves to { server, base, port }. + */ +function startMockServer(handlerOverrides = {}) { + return new Promise((resolve, reject) => { + const server = http.createServer((req, res) => { + const key = `${req.method} ${req.url}`; + const base = `http://127.0.0.1:${server.address().port}`; + const handlers = { ...defaultHandlers(base), ...handlerOverrides(base) }; + const handler = handlers[key]; + if (handler) { + handler(req, res); + } else { + res.writeHead(404, { 'content-type': 'text/plain' }); + res.end('not found'); + } + }); + + server.listen(0, '127.0.0.1', () => { + const port = server.address().port; + resolve({ server, port, base: `http://127.0.0.1:${port}` }); + }); + server.on('error', reject); + }); +} + +// ── CLI / argument-parsing tests (no network needed) ───────────────────────── + +describe('test-production.sh — CLI flags and argument parsing', () => { + it('prints usage and exits 0 with --help', () => { + const { status, stdout } = runScript(['--help']); + assert.strictEqual(status, 0, 'Expected exit code 0'); + assert.ok(stdout.includes('Usage:'), 'Expected "Usage:" in output'); + assert.ok(stdout.includes('bash scripts/test-production.sh'), 'Expected script name in usage'); + }); + + it('prints usage and exits 0 with -h', () => { + const { status, stdout } = runScript(['-h']); + assert.strictEqual(status, 0, 'Expected exit code 0'); + assert.ok(stdout.includes('Usage:'), 'Expected "Usage:" in output'); + }); + + it('exits 2 for an unknown flag', () => { + const { status, stderr } = runScript(['--no-such-flag']); + assert.strictEqual(status, 2, 'Expected exit code 2 for unknown flag'); + assert.ok(stderr.includes('Unknown option'), 'Expected "Unknown option" in stderr'); + }); + + it('exits non-zero when --also is given without a URL argument', () => { + // --also requires the next argument; omitting it should cause a bash error + const { status } = runScript(['https://localhost:1', '--also']); + assert.notStrictEqual(status, 0, 'Expected non-zero exit when --also has no argument'); + }); + + it('exits non-zero when --timeout is given without a value', () => { + const { status } = runScript(['https://localhost:1', '--timeout']); + assert.notStrictEqual(status, 0, 'Expected non-zero exit when --timeout has no value'); + }); + + it('normalises a base URL that includes a path', () => { + // The script strips the path and prints "using origin:" to stderr. + // We can observe this by pointing at a URL with a path on a non-listening + // port; the script will fail on HTTP but should still log the normalisation. + const { stderr } = runScript(['http://127.0.0.1:1/favicon.ico', '--skip-network']); + // The info message goes to stderr + assert.ok( + stderr.includes('using origin:') || stderr.includes('http://127.0.0.1:1'), + 'Expected normalisation message or origin in output', + ); + }); + + it('script file exists and is executable', () => { + const fs = require('fs'); + assert.ok(fs.existsSync(SCRIPT), `Expected ${SCRIPT} to exist`); + // Check execute bit + const mode = fs.statSync(SCRIPT).mode; + // owner-execute bit is 0o100 + assert.ok((mode & 0o111) !== 0, 'Expected script to have execute permission'); + }); + + it('defaults BASE_URL to https://rpc.meechain.live when first arg starts with --', () => { + // Pass --help as the first arg (which is a flag, not a URL), so BASE_URL + // defaults. --help exits 0 so we can inspect stdout safely. + const { status, stdout } = runScript(['--help']); + assert.strictEqual(status, 0); + assert.ok(stdout.includes('https://rpc.meechain.live'), 'Expected default URL in usage text'); + }); +}); + +// ── URL normalisation tests ─────────────────────────────────────────────────── + +describe('test-production.sh — normalize_url behaviour', () => { + it('accepts a plain origin URL without complaint', function(done) { + // Use --help which causes immediate exit; just verify the script can be + // invoked with a clean origin. + const { status } = runScript(['https://example.com', '--help']); + assert.strictEqual(status, 0); + done(); + }); + + it('strips trailing slash from URL', () => { + // We cannot easily isolate normalize_url, but we can check that the script + // does not print the "using origin:" message for a URL that already is an + // origin (no trailing slash). + const { stderr } = runScript(['https://example.com', '--help']); + // There should be no normalisation message for a clean origin + assert.ok(!stderr.includes('using origin:'), 'Should not emit normalisation message for plain origin'); + }); + + it('strips path from trycloudflare URL and emits info message', () => { + const { stderr } = runScript( + ['https://speaker-marshall-stations-antonio.trycloudflare.com/favicon.ico', '--skip-network'], + ); + assert.ok( + stderr.includes('using origin:'), + 'Expected normalisation info message when a path is included in the URL', + ); + }); + + it('fails gracefully when an invalid URL is given', () => { + const { status, stderr } = runScript(['not-a-url', '--skip-network']); + assert.notStrictEqual(status, 0, 'Expected non-zero exit for invalid URL'); + assert.ok( + stderr.includes('invalid base URL') || stderr.includes('not-a-url'), + 'Expected error message mentioning the invalid URL', + ); + }); +}); + +// ── PROD_TEST_ADDITIONAL_URLS env var parsing ───────────────────────────────── + +describe('test-production.sh — PROD_TEST_ADDITIONAL_URLS parsing', () => { + it('accepts an empty PROD_TEST_ADDITIONAL_URLS without error at flag level', () => { + // Just run --help; the env var parsing still runs before the arg loop. + const { status } = runScript(['--help'], { PROD_TEST_ADDITIONAL_URLS: '' }); + assert.strictEqual(status, 0); + }); + + it('parses comma-separated additional URLs from PROD_TEST_ADDITIONAL_URLS', () => { + // We cannot directly observe parsing without running full checks, but we + // can verify the script starts and processes the env var without crashing + // at parse time (before any HTTP calls) by running --help. + const { status } = runScript(['--help'], { + PROD_TEST_ADDITIONAL_URLS: 'https://a.example.com,https://b.example.com', + }); + assert.strictEqual(status, 0); + }); +}); + +// ── Integration tests with a mock HTTP server ───────────────────────────────── + +describe('test-production.sh — happy path with mock server', function() { + // These tests spawn a real shell process and make HTTP calls, so allow extra time. + this.timeout(20000); + + let server; + let base; + + before(async () => { + ({ server, base } = await startMockServer(() => ({}))); + }); + + after(done => { + server.close(done); + }); + + it('exits 0 when all endpoints return correct responses', () => { + const { status, stdout } = runScript([base, '--skip-network']); + assert.strictEqual(status, 0, `Expected exit 0; stderr: ${runScript([base, '--skip-network']).stderr}`); + assert.ok(stdout.includes('✅'), 'Expected at least one pass marker in output'); + assert.ok(stdout.includes('production validation checklist passed'), 'Expected final pass message'); + }); + + it('outputs base URL in the run header', () => { + const { stdout } = runScript([base, '--skip-network']); + assert.ok(stdout.includes('Base URL:'), 'Expected "Base URL:" in output'); + }); + + it('reports all production validation checklists passed', () => { + const { status, stdout } = runScript([base, '--skip-network']); + assert.strictEqual(status, 0); + assert.ok(stdout.includes('all production validation checklists passed')); + }); +}); + +// ── --skip-app flag ─────────────────────────────────────────────────────────── + +describe('test-production.sh — --skip-app flag', function() { + this.timeout(20000); + + let server; + let base; + + before(async () => { + // Serve a root that does NOT contain "MeeChain" to prove the app check is skipped + ({ server, base } = await startMockServer(b => ({ + 'GET /': (req, res) => { + res.writeHead(200, { 'content-type': 'text/html' }); + res.end('No brand text here'); + }, + }))); + }); + + after(done => server.close(done)); + + it('skips the app shell check and still passes when --skip-app is set', () => { + const { status, stdout } = runScript([base, '--skip-app', '--skip-network']); + assert.strictEqual(status, 0, 'Expected exit 0 with --skip-app even when root has no brand text'); + assert.ok(stdout.includes('skipping app shell check') || stdout.includes('production validation checklist passed')); + }); + + it('fails when root does not contain brand text without --skip-app', () => { + const { status } = runScript([base, '--skip-network']); + assert.notStrictEqual(status, 0, 'Expected failure when app shell text is missing'); + }); +}); + +// ── Non-200 response handling ───────────────────────────────────────────────── + +describe('test-production.sh — non-200 responses cause failure', function() { + this.timeout(20000); + + it('exits non-zero when /health returns HTTP 500', async () => { + const { server, base } = await startMockServer(b => ({ + 'GET /health': (req, res) => { + res.writeHead(500, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ status: 'error' })); + }, + })); + try { + const { status, stderr } = runScript([base, '--skip-network']); + assert.notStrictEqual(status, 0, 'Expected non-zero exit for /health HTTP 500'); + assert.ok( + stderr.includes('HTTP 500') || stderr.includes('expected 200'), + 'Expected HTTP status error in stderr', + ); + } finally { + await new Promise(r => server.close(r)); + } + }); + + it('exits non-zero when / returns HTTP 404', async () => { + const { server, base } = await startMockServer(b => ({ + 'GET /': (req, res) => { + res.writeHead(404, { 'content-type': 'text/html' }); + res.end('Not Found'); + }, + })); + try { + const { status } = runScript([base, '--skip-network']); + assert.notStrictEqual(status, 0, 'Expected non-zero exit when app shell returns 404'); + } finally { + await new Promise(r => server.close(r)); + } + }); + + it('exits non-zero when /api/health returns HTTP 503', async () => { + const { server, base } = await startMockServer(b => ({ + 'GET /api/health': (req, res) => { + res.writeHead(503); + res.end('Service Unavailable'); + }, + })); + try { + const { status } = runScript([base, '--skip-network']); + assert.notStrictEqual(status, 0); + } finally { + await new Promise(r => server.close(r)); + } + }); +}); + +// ── Wrong JSON field values ─────────────────────────────────────────────────── + +describe('test-production.sh — wrong JSON values cause failure', function() { + this.timeout(20000); + + it('exits non-zero when /health status is not "ok"', async () => { + const { server, base } = await startMockServer(b => ({ + 'GET /health': (req, res) => { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ status: 'degraded', version: '1.0.0' })); + }, + })); + try { + const { status, stderr } = runScript([base, '--skip-network']); + assert.notStrictEqual(status, 0); + assert.ok( + stderr.includes('degraded') || stderr.includes('expected') || stderr.includes('ok'), + 'Expected mismatch message in stderr', + ); + } finally { + await new Promise(r => server.close(r)); + } + }); + + it('exits non-zero when /rpc/health returns wrong chainId', async () => { + const { server, base } = await startMockServer(b => ({ + 'GET /rpc/health': (req, res) => { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ status: 'ok', chainId: 1, mode: 'proxy' })); + }, + })); + try { + const { status } = runScript([base, '--skip-network']); + assert.notStrictEqual(status, 0, 'Expected failure for wrong chainId in /rpc/health'); + } finally { + await new Promise(r => server.close(r)); + } + }); + + it('exits non-zero when /rpc eth_chainId returns wrong chain ID', async () => { + const { server, base } = await startMockServer(b => ({ + 'POST /rpc': (req, res) => { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ result: '0x1' })); // wrong chain + }, + })); + try { + const { status } = runScript([base, '--skip-network']); + assert.notStrictEqual(status, 0, 'Expected failure for wrong eth_chainId result'); + } finally { + await new Promise(r => server.close(r)); + } + }); + + it('exits non-zero when /health has no version field', async () => { + const { server, base } = await startMockServer(b => ({ + 'GET /health': (req, res) => { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ status: 'ok' })); // missing version + }, + })); + try { + const { status, stderr } = runScript([base, '--skip-network']); + assert.notStrictEqual(status, 0, 'Expected failure when version field is absent'); + assert.ok( + stderr.includes('missing or invalid JSON field') || stderr.includes('version'), + 'Expected JSON field error in stderr', + ); + } finally { + await new Promise(r => server.close(r)); + } + }); +}); + +// ── EXPECT_UPSTREAM_CONNECTED behaviour ─────────────────────────────────────── + +describe('test-production.sh — EXPECT_UPSTREAM_CONNECTED', function() { + this.timeout(20000); + + it('warns but does not fail when connected=false and EXPECT_UPSTREAM_CONNECTED unset', async () => { + const { server, base } = await startMockServer(b => ({ + 'GET /api/web3/status': (req, res) => { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ connected: false })); + }, + })); + try { + const { status, stdout } = runScript([base, '--skip-network']); + assert.strictEqual(status, 0, 'Should not fail when upstream is degraded without EXPECT_UPSTREAM_CONNECTED'); + assert.ok( + stdout.includes('⚠️') || stdout.includes('degraded'), + 'Expected a warning message about degraded upstream', + ); + } finally { + await new Promise(r => server.close(r)); + } + }); + + it('fails when connected=false and EXPECT_UPSTREAM_CONNECTED=1', async () => { + const { server, base } = await startMockServer(b => ({ + 'GET /api/web3/status': (req, res) => { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ connected: false })); + }, + })); + try { + const { status, stderr } = runScript([base, '--skip-network'], { EXPECT_UPSTREAM_CONNECTED: '1' }); + assert.notStrictEqual(status, 0, 'Expected failure when connected=false with EXPECT_UPSTREAM_CONNECTED=1'); + assert.ok( + stderr.includes('expected true') || stderr.includes('false') || stderr.includes('degraded'), + 'Expected failure message in stderr', + ); + } finally { + await new Promise(r => server.close(r)); + } + }); + + it('passes when connected=true regardless of EXPECT_UPSTREAM_CONNECTED', async () => { + const { server, base } = await startMockServer(() => ({})); // default connected:true + try { + const { status } = runScript([base, '--skip-network'], { EXPECT_UPSTREAM_CONNECTED: '1' }); + assert.strictEqual(status, 0); + } finally { + await new Promise(r => server.close(r)); + } + }); +}); + +// ── Cloudflare Access redirect detection ───────────────────────────────────── + +describe('test-production.sh — Cloudflare Access redirect detection', function() { + this.timeout(20000); + + it('exits non-zero when a response has a Location header pointing to cloudflareaccess.com', async () => { + const { server, base } = await startMockServer(b => ({ + 'GET /': (req, res) => { + // Simulate a Cloudflare Access gate redirect (302 with Location header) + res.writeHead(302, { + 'content-type': 'text/html', + 'location': 'https://example.cloudflareaccess.com/cdn-cgi/access/login?...', + }); + res.end('Redirecting...'); + }, + })); + try { + const { status, stderr } = runScript([base, '--skip-network']); + assert.notStrictEqual(status, 0, 'Expected non-zero exit for Cloudflare Access redirect'); + assert.ok( + stderr.includes('cloudflareaccess.com') || stderr.includes('Cloudflare Access'), + 'Expected Cloudflare Access mention in stderr', + ); + } finally { + await new Promise(r => server.close(r)); + } + }); +}); + +// ── --also flag: multiple URLs ──────────────────────────────────────────────── + +describe('test-production.sh — --also flag for additional URLs', function() { + this.timeout(30000); + + it('validates multiple URLs when --also is supplied and exits 0 if both pass', async () => { + const server1Result = await startMockServer(() => ({})); + const server2Result = await startMockServer(() => ({})); + try { + const { status, stdout } = runScript( + [server1Result.base, '--also', server2Result.base, '--skip-network'], + ); + assert.strictEqual(status, 0, 'Expected exit 0 when both --also targets pass'); + // Two complete runs should yield two "checklist passed" messages + const matches = (stdout.match(/production validation checklist passed/g) || []).length; + assert.strictEqual(matches, 2, 'Expected two per-URL pass messages'); + assert.ok(stdout.includes('all production validation checklists passed')); + } finally { + await new Promise(r => server1Result.server.close(r)); + await new Promise(r => server2Result.server.close(r)); + } + }); + + it('exits non-zero when --also URL fails even if primary URL passes', async () => { + const primaryResult = await startMockServer(() => ({})); + // Secondary server returns wrong status + const secondaryResult = await startMockServer(b => ({ + 'GET /health': (req, res) => { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ status: 'down', version: '1.0.0' })); + }, + })); + try { + const { status } = runScript( + [primaryResult.base, '--also', secondaryResult.base, '--skip-network'], + ); + assert.notStrictEqual(status, 0, 'Expected failure when --also URL fails health check'); + } finally { + await new Promise(r => primaryResult.server.close(r)); + await new Promise(r => secondaryResult.server.close(r)); + } + }); +}); + +// ── PROD_TEST_ADDITIONAL_URLS integration ───────────────────────────────────── + +describe('test-production.sh — PROD_TEST_ADDITIONAL_URLS env var', function() { + this.timeout(30000); + + it('validates extra URLs from PROD_TEST_ADDITIONAL_URLS and passes when all pass', async () => { + const s1 = await startMockServer(() => ({})); + const s2 = await startMockServer(() => ({})); + const s3 = await startMockServer(() => ({})); + try { + const { status, stdout } = runScript( + [s1.base, '--skip-network'], + { PROD_TEST_ADDITIONAL_URLS: `${s2.base},${s3.base}` }, + ); + assert.strictEqual(status, 0); + const matches = (stdout.match(/production validation checklist passed/g) || []).length; + assert.strictEqual(matches, 3, 'Expected three per-URL pass messages'); + } finally { + await new Promise(r => s1.server.close(r)); + await new Promise(r => s2.server.close(r)); + await new Promise(r => s3.server.close(r)); + } + }); + + it('ignores whitespace-only entries in PROD_TEST_ADDITIONAL_URLS', async () => { + const s1 = await startMockServer(() => ({})); + try { + // Comma list with spaces only between commas — script strips whitespace + const { status } = runScript( + [s1.base, '--skip-network'], + { PROD_TEST_ADDITIONAL_URLS: ` , , ` }, + ); + // Should still pass with only the primary URL + assert.strictEqual(status, 0); + } finally { + await new Promise(r => s1.server.close(r)); + } + }); +}); + +// ── EXPECTED_CHAIN_ID overrides ─────────────────────────────────────────────── + +describe('test-production.sh — EXPECTED_CHAIN_ID_HEX and EXPECTED_CHAIN_ID_DEC overrides', function() { + this.timeout(20000); + + it('passes when custom chain IDs match server response', async () => { + // Use a non-default chain ID pair + const { server, base } = await startMockServer(b => ({ + 'GET /rpc/health': (req, res) => { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ status: 'ok', chainId: 1, mode: 'proxy' })); + }, + 'POST /rpc': (req, res) => { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ result: '0x1' })); + }, + 'GET /api/network': (req, res) => { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ + chainId: '0x1', + chainIdDecimal: '1', + rpcUrls: [`${b}/rpc`], + })); + }, + })); + try { + const { status } = runScript([base, '--skip-network'], { + EXPECTED_CHAIN_ID_HEX: '0x1', + EXPECTED_CHAIN_ID_DEC: '1', + }); + assert.strictEqual(status, 0, 'Expected pass with matching custom chain IDs'); + } finally { + await new Promise(r => server.close(r)); + } + }); +}); + +// ── Custom EXPECTED_APP_TEXT ────────────────────────────────────────────────── + +describe('test-production.sh — EXPECTED_APP_TEXT env var', function() { + this.timeout(20000); + + it('passes when custom app text is present in root response', async () => { + const { server, base } = await startMockServer(b => ({ + 'GET /': (req, res) => { + res.writeHead(200, { 'content-type': 'text/html' }); + res.end('CustomBrand Dashboard'); + }, + })); + try { + const { status } = runScript([base, '--skip-network'], { EXPECTED_APP_TEXT: 'CustomBrand' }); + assert.strictEqual(status, 0); + } finally { + await new Promise(r => server.close(r)); + } + }); + + it('fails when custom app text is not present in root response', async () => { + const { server, base } = await startMockServer(b => ({ + 'GET /': (req, res) => { + res.writeHead(200, { 'content-type': 'text/html' }); + res.end('MeeChain Dashboard'); + }, + })); + try { + const { status } = runScript([base, '--skip-network'], { EXPECTED_APP_TEXT: 'NonexistentBrand' }); + assert.notStrictEqual(status, 0); + } finally { + await new Promise(r => server.close(r)); + } + }); +}); + +// ── CF Access headers are forwarded ────────────────────────────────────────── + +describe('test-production.sh — Cloudflare Access service token headers', function() { + this.timeout(20000); + + it('forwards CF-Access headers when credentials are set', async () => { + let receivedId = ''; + let receivedSecret = ''; + + const { server, base } = await startMockServer(b => ({ + 'GET /': (req, res) => { + receivedId = req.headers['cf-access-client-id'] || ''; + receivedSecret = req.headers['cf-access-client-secret'] || ''; + res.writeHead(200, { 'content-type': 'text/html' }); + res.end('MeeChain'); + }, + })); + try { + runScript([base, '--skip-network'], { + CF_ACCESS_CLIENT_ID: 'test-client-id', + CF_ACCESS_CLIENT_SECRET: 'test-client-secret', + }); + // Give the request a moment; spawnSync is synchronous so values are set + assert.strictEqual(receivedId, 'test-client-id', 'Expected CF-Access-Client-Id header'); + assert.strictEqual(receivedSecret, 'test-client-secret', 'Expected CF-Access-Client-Secret header'); + } finally { + await new Promise(r => server.close(r)); + } + }); + + it('logs "using Cloudflare Access service token headers" when credentials set', async () => { + const { server, base } = await startMockServer(() => ({})); + try { + const { stdout } = runScript([base, '--skip-network'], { + CF_ACCESS_CLIENT_ID: 'id123', + CF_ACCESS_CLIENT_SECRET: 'secret456', + }); + assert.ok( + stdout.includes('Cloudflare Access service token'), + 'Expected Cloudflare Access token log message', + ); + } finally { + await new Promise(r => server.close(r)); + } + }); +}); + +// ── --timeout flag ──────────────────────────────────────────────────────────── + +describe('test-production.sh — --timeout flag', function() { + this.timeout(20000); + + it('accepts a custom --timeout value and passes it through', async () => { + const { server, base } = await startMockServer(() => ({})); + try { + const { status } = runScript([base, '--skip-network', '--timeout', '5']); + assert.strictEqual(status, 0, 'Expected exit 0 with custom --timeout value'); + } finally { + await new Promise(r => server.close(r)); + } + }); +}); + +// ── normalize_dns_name logic (tested via check_expected_cname_target) ───────── + +describe('test-production.sh — normalize_dns_name / EXPECTED_CNAME_TARGET', function() { + this.timeout(20000); + + it('issues a warning (not failure) when CNAME is not visible and EXPECTED_CNAME_STRICT unset', async () => { + // Use localhost which has no CNAME record; the script should warn + const { server, base } = await startMockServer(() => ({})); + // Enable network so CNAME lookup runs, but use localhost (no CNAME) + try { + const { status, stdout } = runScript([base], { + EXPECTED_CNAME_TARGET: 'some-tunnel.cfargotunnel.com', + EXPECTED_CNAME_STRICT: '0', + }); + // Should still exit 0 (warn, not fail) + assert.strictEqual(status, 0, 'Expected exit 0 (warning) when CNAME not visible without strict mode'); + assert.ok( + stdout.includes('⚠️') || stdout.includes('no CNAME visible'), + 'Expected a warning about CNAME not being visible', + ); + } finally { + await new Promise(r => server.close(r)); + } + }); + + it('fails when CNAME is not visible and EXPECTED_CNAME_STRICT=1', async () => { + const { server, base } = await startMockServer(() => ({})); + try { + const { status, stderr } = runScript([base], { + EXPECTED_CNAME_TARGET: 'some-tunnel.cfargotunnel.com', + EXPECTED_CNAME_STRICT: '1', + }); + assert.notStrictEqual(status, 0, 'Expected failure in strict CNAME mode when CNAME is not visible'); + assert.ok( + stderr.includes('CNAME') || stderr.includes('does not visibly match'), + 'Expected CNAME mismatch error in stderr', + ); + } finally { + await new Promise(r => server.close(r)); + } + }); +}); + +// ── Regression: script passes with --skip-network --skip-app ───────────────── + +describe('test-production.sh — combined skip flags', function() { + this.timeout(20000); + + it('exits 0 with --skip-network --skip-app when health/rpc endpoints pass', async () => { + // Root is intentionally blank; with --skip-app and --skip-network neither + // DNS nor the app shell are checked. + const { server, base } = await startMockServer(b => ({ + 'GET /': (req, res) => { + res.writeHead(200, { 'content-type': 'text/plain' }); + res.end('no brand text'); + }, + })); + try { + const { status } = runScript([base, '--skip-network', '--skip-app']); + assert.strictEqual(status, 0); + } finally { + await new Promise(r => server.close(r)); + } + }); +});