178 lines
5.7 KiB
Bash
Executable File

#!/usr/bin/env bash
# tests/lifecycle/lib/rpc.bash
#
# Shared JSON-RPC client for archipelago lifecycle tests.
# Handles login, session cookie + CSRF token management, and request plumbing.
#
# Environment variables honored:
# ARCHY_HOST — default: 127.0.0.1
# ARCHY_SCHEME — default: https
# ARCHY_PASSWORD — REQUIRED. The UI password.
#
# After sourcing, call `rpc_login` once per test file in setup_file or setup.
# Then call `rpc_call METHOD [JSON_PARAMS]` to invoke methods.
# rpc_call prints the raw JSON response to stdout.
set -euo pipefail
ARCHY_HOST="${ARCHY_HOST:-127.0.0.1}"
ARCHY_SCHEME="${ARCHY_SCHEME:-https}"
ARCHY_BASE_URL="${ARCHY_SCHEME}://${ARCHY_HOST}"
# Session file lives in a stable per-user location so every bats subshell
# (setup_file, setup, each @test) sees the same cookies. File format:
# line 1: session cookie value
# line 2: csrf cookie value
RPC_SESSION_FILE="${RPC_SESSION_FILE:-${TMPDIR:-/tmp}/archy-rpc-session-${UID:-$(id -u)}}"
RPC_SESSION=""
RPC_CSRF=""
# Load cookies from $RPC_SESSION_FILE into RPC_SESSION/RPC_CSRF.
# Returns 1 if the file is missing or malformed.
_rpc_load_session() {
[[ -r "$RPC_SESSION_FILE" ]] || return 1
local lines
mapfile -t lines < "$RPC_SESSION_FILE"
RPC_SESSION="${lines[0]:-}"
RPC_CSRF="${lines[1]:-}"
[[ -n "$RPC_SESSION" && -n "$RPC_CSRF" ]]
}
# Log in with $ARCHY_PASSWORD and persist session + csrf cookies to $RPC_SESSION_FILE.
# Idempotent-ish: if a valid session file already exists and ARCHY_FORCE_LOGIN
# is not set, we reuse it (saves a round-trip per test file).
rpc_login() {
if _rpc_load_session && [[ -z "${ARCHY_FORCE_LOGIN:-}" ]]; then
return 0
fi
if [[ -z "${ARCHY_PASSWORD:-}" ]]; then
echo "rpc_login: ARCHY_PASSWORD env var not set" >&2
return 1
fi
local headers body
headers=$(mktemp)
body=$(curl -sk -D "$headers" -X POST "${ARCHY_BASE_URL}/rpc/v1" \
-H 'Content-Type: application/json' \
--data-raw "{\"jsonrpc\":\"2.0\",\"method\":\"auth.login\",\"params\":{\"password\":\"${ARCHY_PASSWORD}\"},\"id\":1}")
local err
err=$(echo "$body" | jq -r '.error // empty')
if [[ -n "$err" && "$err" != "null" ]]; then
echo "rpc_login failed: $err" >&2
rm -f "$headers"
return 1
fi
RPC_SESSION=$(grep -i '^set-cookie: session=' "$headers" | head -1 | sed -E 's/.*session=([^;]+).*/\1/' | tr -d '\r')
RPC_CSRF=$(grep -i '^set-cookie: csrf_token=' "$headers" | head -1 | sed -E 's/.*csrf_token=([^;]+).*/\1/' | tr -d '\r')
rm -f "$headers"
if [[ -z "$RPC_SESSION" || -z "$RPC_CSRF" ]]; then
echo "rpc_login: missing session or csrf cookie in response" >&2
return 1
fi
# Persist for subsequent subshells.
umask 077
printf '%s\n%s\n' "$RPC_SESSION" "$RPC_CSRF" > "$RPC_SESSION_FILE"
return 0
}
# Forget persisted session (e.g., at end of a test run).
rpc_logout_local() {
rm -f "$RPC_SESSION_FILE"
RPC_SESSION=""
RPC_CSRF=""
}
# Call an RPC method.
# Usage: rpc_call METHOD [PARAMS_JSON]
# Prints the full JSON-RPC response object to stdout.
# Returns 0 on successful HTTP call (regardless of RPC-level error).
rpc_call() {
local method="$1"
local params="${2:-null}"
local id="${3:-$RANDOM}"
if [[ -z "$RPC_SESSION" || -z "$RPC_CSRF" ]]; then
_rpc_load_session || {
echo "rpc_call: not logged in (call rpc_login first)" >&2
return 1
}
fi
local payload
if [[ "$params" == "null" ]]; then
payload=$(jq -nc --arg m "$method" --argjson id "$id" '{jsonrpc:"2.0",method:$m,id:$id}')
else
payload=$(jq -nc --arg m "$method" --argjson id "$id" --argjson p "$params" '{jsonrpc:"2.0",method:$m,params:$p,id:$id}')
fi
curl -sk -X POST "${ARCHY_BASE_URL}/rpc/v1" \
-H 'Content-Type: application/json' \
-H "Cookie: session=${RPC_SESSION}; csrf_token=${RPC_CSRF}" \
-H "X-CSRF-Token: ${RPC_CSRF}" \
--data-raw "$payload"
}
# Convenience: call rpc and return only the .result field (or fail if .error is set).
rpc_result() {
local resp
resp=$(rpc_call "$@")
local err
err=$(echo "$resp" | jq -r '.error // empty')
if [[ -n "$err" && "$err" != "null" ]]; then
echo "rpc_result: $1 failed: $err" >&2
echo "full response: $resp" >&2
return 1
fi
echo "$resp" | jq '.result'
}
# Wait for a container to reach a given status ("running" or "stopped" or "absent").
# Usage: wait_for_container_status NAME STATUS [TIMEOUT_SECONDS]
wait_for_container_status() {
local name="$1"
local target="$2"
local timeout="${3:-60}"
local deadline=$(( $(date +%s) + timeout ))
while (( $(date +%s) < deadline )); do
local list state status
list=$(rpc_result container-list 2>/dev/null || echo '[]')
if [[ "$target" == "absent" ]]; then
if ! echo "$list" | jq -e --arg n "$name" '.[] | select(.name == $n)' >/dev/null 2>&1; then
return 0
fi
else
# Primary source: container-list state keyed by container name.
state=$(echo "$list" | jq -r --arg n "$name" '.[] | select(.name == $n) | .state // "unknown"')
if [[ "$state" == "$target" ]]; then
return 0
fi
# Fallback: container-status RPC accepts app_id. For common UI-prefixed
# names, strip archy- prefix before querying.
local app_id="$name"
if [[ $app_id == bitcoin-knots ]]; then
app_id=bitcoin-core
elif [[ $app_id == electrs || $app_id == mempool-electrs ]]; then
app_id=electrumx
elif [[ $app_id == archy-* ]]; then
app_id=${app_id#archy-}
fi
status=$(rpc_result container-status "{\"app_id\":\"$app_id\"}" 2>/dev/null | jq -r '.status // .state // "unknown"')
if [[ "$status" == "$target" ]]; then
return 0
fi
fi
sleep 2
done
echo "wait_for_container_status: $name did not reach '$target' within ${timeout}s" >&2
return 1
}