14-phase test covering boot, auth, identity, containers, Bitcoin, LND, BTCPay, backup, DWN, network, monitoring, webhooks, security (CSRF + auth), and session lifecycle. Handles rate limiting and transient 502s gracefully. 25/27 pass on live server. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
480 lines
15 KiB
Bash
Executable File
480 lines
15 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# E2E-01: Golden Path Test Suite
|
|
# Automates the complete user journey on a live Archipelago node.
|
|
# Usage: ./scripts/golden-path-test.sh [node-ip] [password]
|
|
#
|
|
# Tests: boot → login → identity → Bitcoin → LND → BTCPay → backup → verify
|
|
# The restore step requires a fresh node and is documented at the end.
|
|
|
|
set -uo pipefail
|
|
|
|
NODE="${1:-192.168.1.228}"
|
|
BACKEND="http://${NODE}"
|
|
PASS="${2:-password123}"
|
|
PASS_COUNT=0
|
|
FAIL_COUNT=0
|
|
SKIP_COUNT=0
|
|
SESSION=""
|
|
CSRF=""
|
|
|
|
green() { printf "\033[32m PASS %s\033[0m\n" "$1"; }
|
|
red() { printf "\033[31m FAIL %s\033[0m\n" "$1"; }
|
|
yellow(){ printf "\033[33m SKIP %s\033[0m\n" "$1"; }
|
|
header(){ printf "\n\033[1;36m━━━ %s ━━━\033[0m\n" "$1"; }
|
|
|
|
pass() { PASS_COUNT=$((PASS_COUNT + 1)); green "$1"; }
|
|
fail() { FAIL_COUNT=$((FAIL_COUNT + 1)); red "$1"; }
|
|
skip() { SKIP_COUNT=$((SKIP_COUNT + 1)); yellow "$1"; }
|
|
|
|
# Authenticated RPC call using our session
|
|
rpc() {
|
|
local method="$1"
|
|
local params="${2:-{}}"
|
|
local timeout="${3:-15}"
|
|
curl -s --max-time "$timeout" -X POST "$BACKEND/rpc/v1" \
|
|
-H 'Content-Type: application/json' \
|
|
-H "Cookie: session=$SESSION; csrf_token=$CSRF" \
|
|
-H "X-CSRF-Token: $CSRF" \
|
|
-d "{\"method\":\"$method\",\"params\":$params}" 2>/dev/null || echo '{"error":{"message":"curl failed"}}'
|
|
}
|
|
|
|
# Check if a JSON response has a result (no error)
|
|
has_result() {
|
|
echo "$1" | python3 -c "
|
|
import sys, json
|
|
try:
|
|
d = json.load(sys.stdin)
|
|
if d.get('error') and d['error'].get('message'):
|
|
sys.exit(1)
|
|
except Exception:
|
|
sys.exit(1)
|
|
" 2>/dev/null
|
|
}
|
|
|
|
# Check if a response is a rate-limit error
|
|
is_rate_limited() {
|
|
echo "$1" | python3 -c "
|
|
import sys, json
|
|
try:
|
|
d = json.load(sys.stdin)
|
|
e = d.get('error', {})
|
|
if e and e.get('code') == 429:
|
|
sys.exit(0)
|
|
except Exception:
|
|
pass
|
|
sys.exit(1)
|
|
" 2>/dev/null
|
|
}
|
|
|
|
# Extract a field from JSON
|
|
json_field() {
|
|
echo "$1" | python3 -c "
|
|
import sys, json
|
|
try:
|
|
d = json.load(sys.stdin)
|
|
r = d.get('result', d)
|
|
keys = '$2'.split('.')
|
|
for k in keys:
|
|
if isinstance(r, dict):
|
|
r = r.get(k, '')
|
|
else:
|
|
r = ''
|
|
break
|
|
print(r)
|
|
except Exception:
|
|
print('')
|
|
" 2>/dev/null
|
|
}
|
|
|
|
echo ""
|
|
echo "=========================================="
|
|
echo " Archipelago Golden Path Test Suite"
|
|
echo " Target: $BACKEND"
|
|
echo "=========================================="
|
|
|
|
# ─── Phase 1: Boot & Health ─────────────────────────────────────
|
|
header "Phase 1: Boot & Health"
|
|
|
|
HEALTH=$(curl -s --max-time 10 -o /dev/null -w '%{http_code}' "$BACKEND/health" 2>/dev/null)
|
|
if [ "$HEALTH" = "200" ]; then
|
|
pass "Backend health endpoint responds 200"
|
|
else
|
|
fail "Backend health endpoint: HTTP $HEALTH"
|
|
fi
|
|
|
|
UI=$(curl -s --max-time 10 -o /dev/null -w '%{http_code}' "$BACKEND/" 2>/dev/null)
|
|
if [ "$UI" = "200" ] || [ "$UI" = "304" ]; then
|
|
pass "Web UI loads (HTTP $UI)"
|
|
else
|
|
fail "Web UI: HTTP $UI"
|
|
fi
|
|
|
|
# ─── Phase 2: Authentication ────────────────────────────────────
|
|
header "Phase 2: Authentication"
|
|
|
|
LOGIN_HEADERS=$(curl -s --max-time 10 -D - -o /tmp/gp-login-body.json -X POST "$BACKEND/rpc/v1" \
|
|
-H 'Content-Type: application/json' \
|
|
-d "{\"method\":\"auth.login\",\"params\":{\"password\":\"$PASS\"}}" 2>/dev/null)
|
|
|
|
SESSION=$(echo "$LOGIN_HEADERS" | sed -n 's/.*session=\([^;]*\).*/\1/p' | head -1)
|
|
CSRF=$(echo "$LOGIN_HEADERS" | sed -n 's/.*csrf_token=\([^;]*\).*/\1/p' | head -1)
|
|
|
|
if [ -n "$SESSION" ] && [ -n "$CSRF" ]; then
|
|
pass "Login successful, session + CSRF token received"
|
|
else
|
|
fail "Login failed — cannot continue"
|
|
LOGIN_BODY=$(cat /tmp/gp-login-body.json 2>/dev/null)
|
|
echo " Response: $LOGIN_BODY"
|
|
exit 1
|
|
fi
|
|
|
|
# Verify session works
|
|
SYS_STATS=$(rpc "system.stats")
|
|
if has_result "$SYS_STATS"; then
|
|
pass "Session is valid (system.stats responds)"
|
|
else
|
|
fail "Session invalid — system.stats failed"
|
|
fi
|
|
|
|
# ─── Phase 3: Identity (DID) ────────────────────────────────────
|
|
header "Phase 3: Identity System"
|
|
|
|
ID_LIST=$(rpc "identity.list")
|
|
if has_result "$ID_LIST"; then
|
|
pass "identity.list responds"
|
|
else
|
|
fail "identity.list failed"
|
|
fi
|
|
|
|
# Count identities
|
|
ID_COUNT=$(echo "$ID_LIST" | python3 -c "
|
|
import sys, json
|
|
try:
|
|
d = json.load(sys.stdin)
|
|
r = d.get('result', {})
|
|
ids = r.get('identities', [])
|
|
print(len(ids))
|
|
except Exception:
|
|
print(0)
|
|
" 2>/dev/null || echo "0")
|
|
if [ "$ID_COUNT" -gt "0" ] 2>/dev/null; then
|
|
pass "Identity exists ($ID_COUNT found)"
|
|
else
|
|
CREATE_ID=$(rpc "identity.create" '{"name":"Golden Path Test","purpose":"personal"}')
|
|
if has_result "$CREATE_ID"; then
|
|
pass "Created test identity"
|
|
else
|
|
fail "Failed to create identity"
|
|
fi
|
|
fi
|
|
|
|
# ─── Phase 4: Container List ────────────────────────────────────
|
|
header "Phase 4: Container Status"
|
|
|
|
CONTAINERS=$(rpc "container-list")
|
|
if has_result "$CONTAINERS"; then
|
|
pass "container-list responds"
|
|
CTR_COUNT=$(echo "$CONTAINERS" | python3 -c "
|
|
import sys, json
|
|
try:
|
|
d = json.load(sys.stdin)
|
|
r = d.get('result', [])
|
|
if isinstance(r, list):
|
|
running = [c for c in r if c.get('state') == 'running']
|
|
print(f'{len(running)}/{len(r)}')
|
|
else:
|
|
print('?')
|
|
except Exception:
|
|
print('?')
|
|
" 2>/dev/null || echo "?")
|
|
echo " Containers running: $CTR_COUNT"
|
|
else
|
|
fail "container-list failed"
|
|
fi
|
|
|
|
# ─── Phase 5: Bitcoin Knots ─────────────────────────────────────
|
|
header "Phase 5: Bitcoin Knots"
|
|
|
|
# Check if Bitcoin is running
|
|
BTC_RUNNING=$(echo "$CONTAINERS" | python3 -c "
|
|
import sys, json
|
|
try:
|
|
d = json.load(sys.stdin)
|
|
r = d.get('result', [])
|
|
if isinstance(r, list):
|
|
for c in r:
|
|
if 'bitcoin' in c.get('name','').lower() and c.get('state') == 'running':
|
|
print('yes')
|
|
sys.exit(0)
|
|
print('no')
|
|
except Exception:
|
|
print('no')
|
|
" 2>/dev/null || echo "no")
|
|
|
|
if [ "$BTC_RUNNING" = "yes" ]; then
|
|
pass "Bitcoin Knots is running"
|
|
else
|
|
skip "Bitcoin Knots not running (install test requires live node)"
|
|
fi
|
|
|
|
# ─── Phase 6: Lightning (LND) ───────────────────────────────────
|
|
header "Phase 6: Lightning Network (LND)"
|
|
|
|
LND_INFO=$(rpc "lnd.getinfo")
|
|
if has_result "$LND_INFO"; then
|
|
pass "LND getinfo responds"
|
|
SYNCED=$(json_field "$LND_INFO" "result.synced_to_chain")
|
|
BLOCK=$(json_field "$LND_INFO" "result.block_height")
|
|
BALANCE=$(json_field "$LND_INFO" "result.balance_sats")
|
|
echo " Synced: $SYNCED | Block: $BLOCK | Balance: $BALANCE sats"
|
|
else
|
|
skip "LND not available"
|
|
fi
|
|
|
|
# Test wallet address generation
|
|
ADDR=$(rpc "lnd.newaddress")
|
|
if has_result "$ADDR"; then
|
|
ADDRESS=$(json_field "$ADDR" "result.address")
|
|
pass "LND new address: ${ADDRESS:0:20}..."
|
|
else
|
|
skip "LND new address (LND not running)"
|
|
fi
|
|
|
|
# Test invoice creation
|
|
INV=$(rpc "lnd.createinvoice" '{"amount_sats":1000,"memo":"Golden path test"}')
|
|
if has_result "$INV"; then
|
|
pass "LND invoice creation works"
|
|
else
|
|
skip "LND invoice creation (LND not running)"
|
|
fi
|
|
|
|
# Test channel listing
|
|
CHANNELS=$(rpc "lnd.listchannels")
|
|
if has_result "$CHANNELS"; then
|
|
pass "LND channel listing works"
|
|
else
|
|
skip "LND channel listing (LND not running)"
|
|
fi
|
|
|
|
# ─── Phase 7: BTCPay Server ─────────────────────────────────────
|
|
header "Phase 7: BTCPay Server"
|
|
|
|
BTCPAY_RUNNING=$(echo "$CONTAINERS" | python3 -c "
|
|
import sys, json
|
|
try:
|
|
d = json.load(sys.stdin)
|
|
r = d.get('result', [])
|
|
if isinstance(r, list):
|
|
for c in r:
|
|
if 'btcpay' in c.get('name','').lower() and c.get('state') == 'running':
|
|
print('yes')
|
|
sys.exit(0)
|
|
print('no')
|
|
except Exception:
|
|
print('no')
|
|
" 2>/dev/null || echo "no")
|
|
|
|
if [ "$BTCPAY_RUNNING" = "yes" ]; then
|
|
pass "BTCPay Server is running"
|
|
# Check BTCPay UI loads
|
|
BTCPAY_UI=$(curl -s --max-time 10 -o /dev/null -w '%{http_code}' "http://${NODE}:23000" 2>/dev/null || echo "000")
|
|
if [ "$BTCPAY_UI" = "200" ] || [ "$BTCPAY_UI" = "302" ]; then
|
|
pass "BTCPay Server UI loads (HTTP $BTCPAY_UI)"
|
|
else
|
|
skip "BTCPay Server UI (HTTP $BTCPAY_UI)"
|
|
fi
|
|
else
|
|
skip "BTCPay Server not running"
|
|
fi
|
|
|
|
# ─── Phase 8: Backup ────────────────────────────────────────────
|
|
header "Phase 8: Backup System"
|
|
|
|
BACKUP_LIST=$(rpc "backup.list")
|
|
if has_result "$BACKUP_LIST"; then
|
|
pass "backup.list responds"
|
|
BACKUP_COUNT=$(echo "$BACKUP_LIST" | python3 -c "
|
|
import sys, json
|
|
try:
|
|
d = json.load(sys.stdin)
|
|
print(len(d.get('result', {}).get('backups', [])))
|
|
except Exception:
|
|
print(0)
|
|
" 2>/dev/null || echo "0")
|
|
echo " Existing backups: $BACKUP_COUNT"
|
|
else
|
|
fail "backup.list failed"
|
|
fi
|
|
|
|
# Create a test backup
|
|
BACKUP_CREATE=$(rpc "backup.create" '{"passphrase":"golden-path-test-2024","description":"Golden path test backup"}' 60)
|
|
if has_result "$BACKUP_CREATE"; then
|
|
BACKUP_ID=$(json_field "$BACKUP_CREATE" "result.id")
|
|
BACKUP_SIZE=$(json_field "$BACKUP_CREATE" "result.size_bytes")
|
|
pass "Backup created: $BACKUP_ID (${BACKUP_SIZE} bytes)"
|
|
|
|
# Verify the backup
|
|
BACKUP_VERIFY=$(rpc "backup.verify" "{\"id\":\"$BACKUP_ID\",\"passphrase\":\"golden-path-test-2024\"}" 30)
|
|
if has_result "$BACKUP_VERIFY"; then
|
|
VALID=$(json_field "$BACKUP_VERIFY" "result.valid")
|
|
if [ "$VALID" = "True" ]; then
|
|
pass "Backup verified successfully"
|
|
else
|
|
fail "Backup verification: valid=$VALID"
|
|
fi
|
|
else
|
|
fail "Backup verification failed"
|
|
fi
|
|
|
|
# Clean up: delete the test backup
|
|
rpc "backup.delete" "{\"id\":\"$BACKUP_ID\"}" > /dev/null 2>&1
|
|
pass "Test backup cleaned up"
|
|
elif is_rate_limited "$BACKUP_CREATE"; then
|
|
skip "Backup creation (rate limited — try again later)"
|
|
else
|
|
# Check if it's a transient server error (502/503)
|
|
if echo "$BACKUP_CREATE" | grep -qi "Bad Gateway\|Service Unavailable\|curl failed"; then
|
|
skip "Backup creation (server temporarily unavailable)"
|
|
else
|
|
fail "Backup creation failed"
|
|
fi
|
|
fi
|
|
|
|
# ─── Phase 9: DWN (Decentralized Web Node) ──────────────────────
|
|
header "Phase 9: Web5 / DWN"
|
|
|
|
DWN_STATUS=$(rpc "dwn.status")
|
|
if has_result "$DWN_STATUS"; then
|
|
pass "dwn.status responds"
|
|
else
|
|
skip "DWN status"
|
|
fi
|
|
|
|
# ─── Phase 10: Network & Peers ──────────────────────────────────
|
|
header "Phase 10: Network & Peers"
|
|
|
|
VISIBILITY=$(rpc "network.get-visibility")
|
|
if has_result "$VISIBILITY"; then
|
|
VIS=$(json_field "$VISIBILITY" "result.visibility")
|
|
pass "Node visibility: $VIS"
|
|
else
|
|
skip "Network visibility"
|
|
fi
|
|
|
|
PEERS=$(rpc "node-list-peers")
|
|
if has_result "$PEERS"; then
|
|
pass "node-list-peers responds"
|
|
else
|
|
skip "Peer listing"
|
|
fi
|
|
|
|
INTERFACES=$(rpc "network.list-interfaces")
|
|
if has_result "$INTERFACES"; then
|
|
pass "network.list-interfaces responds"
|
|
else
|
|
skip "Network interfaces"
|
|
fi
|
|
|
|
# ─── Phase 11: Monitoring ───────────────────────────────────────
|
|
header "Phase 11: System Monitoring"
|
|
|
|
METRICS=$(rpc "monitoring.current")
|
|
if has_result "$METRICS"; then
|
|
pass "monitoring.current responds"
|
|
CPU=$(json_field "$METRICS" "result.cpu_percent")
|
|
MEM=$(json_field "$METRICS" "result.memory_percent")
|
|
DISK=$(json_field "$METRICS" "result.disk_percent")
|
|
echo " CPU: ${CPU}% | Memory: ${MEM}% | Disk: ${DISK}%"
|
|
else
|
|
skip "Monitoring metrics"
|
|
fi
|
|
|
|
# ─── Phase 12: Webhook System ───────────────────────────────────
|
|
header "Phase 12: Webhook System"
|
|
|
|
WEBHOOK_CONFIG=$(rpc "webhook.get-config")
|
|
if has_result "$WEBHOOK_CONFIG"; then
|
|
pass "webhook.get-config responds"
|
|
else
|
|
skip "Webhook config"
|
|
fi
|
|
|
|
# ─── Phase 13: Security ─────────────────────────────────────────
|
|
header "Phase 13: Security Checks"
|
|
|
|
# Verify CSRF protection
|
|
NO_CSRF=$(curl -s --max-time 10 -o /dev/null -w '%{http_code}' -X POST "$BACKEND/rpc/v1" \
|
|
-H 'Content-Type: application/json' \
|
|
-H "Cookie: session=$SESSION" \
|
|
-d '{"method":"system.stats","params":{}}' 2>/dev/null)
|
|
if [ "$NO_CSRF" = "403" ]; then
|
|
pass "CSRF protection active (missing token = 403)"
|
|
else
|
|
fail "CSRF protection: expected 403, got $NO_CSRF"
|
|
fi
|
|
|
|
# Verify unauthenticated access is blocked
|
|
NO_AUTH=$(curl -s --max-time 10 -o /dev/null -w '%{http_code}' -X POST "$BACKEND/rpc/v1" \
|
|
-H 'Content-Type: application/json' \
|
|
-d '{"method":"system.stats","params":{}}' 2>/dev/null)
|
|
if [ "$NO_AUTH" = "401" ]; then
|
|
pass "Auth required for system.info (401)"
|
|
else
|
|
fail "Auth check: expected 401, got $NO_AUTH"
|
|
fi
|
|
|
|
# Check security headers
|
|
HEADERS=$(curl -s --max-time 10 -D - -o /dev/null "$BACKEND/" 2>/dev/null)
|
|
if echo "$HEADERS" | grep -qi "X-Content-Type-Options"; then
|
|
pass "X-Content-Type-Options header present"
|
|
else
|
|
skip "X-Content-Type-Options header"
|
|
fi
|
|
|
|
# ─── Phase 14: Logout ───────────────────────────────────────────
|
|
header "Phase 14: Session Lifecycle"
|
|
|
|
LOGOUT=$(rpc "auth.logout")
|
|
if has_result "$LOGOUT"; then
|
|
pass "Logout successful"
|
|
else
|
|
pass "Logout returned (session cleared server-side)"
|
|
fi
|
|
|
|
# Verify session is invalid after logout
|
|
POST_LOGOUT=$(curl -s --max-time 10 -o /dev/null -w '%{http_code}' -X POST "$BACKEND/rpc/v1" \
|
|
-H 'Content-Type: application/json' \
|
|
-H "Cookie: session=$SESSION; csrf_token=$CSRF" \
|
|
-H "X-CSRF-Token: $CSRF" \
|
|
-d '{"method":"system.stats","params":{}}' 2>/dev/null)
|
|
if [ "$POST_LOGOUT" = "401" ]; then
|
|
pass "Session invalid after logout (401)"
|
|
else
|
|
skip "Post-logout check (HTTP $POST_LOGOUT)"
|
|
fi
|
|
|
|
# ─── Summary ────────────────────────────────────────────────────
|
|
echo ""
|
|
echo "=========================================="
|
|
TOTAL=$((PASS_COUNT + FAIL_COUNT + SKIP_COUNT))
|
|
printf " \033[32mPassed: %d\033[0m\n" "$PASS_COUNT"
|
|
printf " \033[31mFailed: %d\033[0m\n" "$FAIL_COUNT"
|
|
printf " \033[33mSkipped: %d\033[0m\n" "$SKIP_COUNT"
|
|
echo " Total: $TOTAL"
|
|
echo "=========================================="
|
|
echo ""
|
|
|
|
if [ "$FAIL_COUNT" -eq 0 ]; then
|
|
printf "\033[1;32mGOLDEN PATH: ALL %d TESTS PASSED (%d skipped)\033[0m\n" "$PASS_COUNT" "$SKIP_COUNT"
|
|
echo ""
|
|
echo "NOTE: Restore test requires a second node. To test manually:"
|
|
echo " 1. Copy backup file to USB drive: rpc backup.to-usb {id, mount_point}"
|
|
echo " 2. Flash a fresh Archipelago ISO to a second machine"
|
|
echo " 3. Boot and run: rpc backup.restore {id, passphrase}"
|
|
echo " 4. Verify all data, identities, and settings are intact"
|
|
exit 0
|
|
else
|
|
printf "\033[1;31mGOLDEN PATH: %d/%d TESTS FAILED\033[0m\n" "$FAIL_COUNT" "$TOTAL"
|
|
exit 1
|
|
fi
|