Files
ssmithxandClaude Opus 5.5 75b61aa7df test(routstr): wait for Routstr to answer before reading held earnings
The selling status reads Routstr's admin API live. For about 20s after
its container reports running, Routstr refuses connections, so a single
read right after a restart or reinstall returned payout null and the
gate's held-earnings checks failed (pa3, 2026-10-26: the cascade
reinstall test and one read-only check, with 77 sats held throughout).
payout_status now polls for up to 90s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 04:23:51 +00:00

202 lines
8.2 KiB
Bash

#!/usr/bin/env bats
# tests/lifecycle/bats/routstr.bats
#
# Lifecycle tests for selling local AI over FIPS: the llama-server app (model
# runtime) and the routstr app (payment gateway, holding the seller's ecash).
#
# Tiers:
# - Read-only (always): presence, state, catalog, fail-closed checks
# - Destructive (ARCHY_ALLOW_DESTRUCTIVE=1): stop → start → restart of each
# app, with the price and the held earnings unchanged afterwards
# - Cascade (ARCHY_ALLOW_CASCADE_DESTRUCTIVE=1): package.update and
# uninstall (preserve_data) → reinstall of routstr, with the held earnings
# unchanged. Routstr's data directory holds real ecash: back it up first.
#
# The catalog and gateway checks talk to 127.0.0.1 and only run on the node.
load '../lib/rpc.bash'
ROUTSTR_IMAGE="localhost/archy-routstr:0.5.0-archy3"
ROUTSTR_API="http://127.0.0.1:8200"
setup_file() {
: "${ARCHY_PASSWORD:?Set ARCHY_PASSWORD env var to the UI password}"
export ARCHY_FORCE_LOGIN=1
rpc_login
unset ARCHY_FORCE_LOGIN
}
teardown_file() {
rpc_logout_local
}
on_node() {
[[ "${ARCHY_HOST:-127.0.0.1}" == "127.0.0.1" || "${ARCHY_HOST:-}" == "localhost" ]]
}
# The archy-llama catalog entry, once Routstr answers (it re-applies wallet
# settings and the model entry for a few seconds after every start).
catalog_entry() {
local deadline=$(( $(date +%s) + ${1:-120} ))
while (( $(date +%s) < deadline )); do
local entry
entry=$(curl -fsS -m 5 "$ROUTSTR_API/v1/models" 2>/dev/null \
| jq -c '.data[] | select(.id == "archy-llama")' 2>/dev/null) || true
if [[ -n "$entry" ]]; then
echo "$entry"
return 0
fi
sleep 3
done
echo "routstr did not list archy-llama within ${1:-120}s" >&2
return 1
}
# The node's selling status, once Routstr answers its admin API. The status
# is a live read, and Routstr refuses connections for ~20s after its
# container reports running (pa3, 2026-10-26), so wait rather than read once.
payout_status() {
local deadline=$(( $(date +%s) + ${1:-90} ))
local status
while (( $(date +%s) < deadline )); do
status=$(rpc_result system.settings.get '{"key":"routstr_selling_status"}' 2>/dev/null) || status=""
if echo "$status" | jq -e '.value.payout != null' >/dev/null 2>&1; then
echo "$status"
return 0
fi
sleep 3
done
echo "$status"
return 1
}
held_sats() {
payout_status | jq -er '.value.payout.held_sats'
}
# ────────────────────────────────────────────────────────────────────
# Read-only tier
# ────────────────────────────────────────────────────────────────────
@test "container-list reports llama-server and routstr running" {
run rpc_result container-list
[ "$status" -eq 0 ]
for app in llama-server routstr; do
echo "$output" | jq -e --arg app "$app" \
'.[] | select(.name == $app and .state == "running")' >/dev/null
done
}
@test "routstr serves the local model priced, with its real context size" {
on_node || skip "catalog is host-local"
run catalog_entry
[ "$status" -eq 0 ]
echo "$output" | jq -e '.context_length >= 8192' >/dev/null
echo "$output" | jq -e '(.pricing.prompt | tonumber) > 0 and (.pricing.completion | tonumber) > 0' >/dev/null
echo "$output" | jq -e '.sats_pricing.max_cost > 0' >/dev/null
}
@test "routstr trusts only explicit https mints (never an empty fallback)" {
on_node || skip "info is host-local"
run curl -fsS -m 5 "$ROUTSTR_API/v1/info"
[ "$status" -eq 0 ]
echo "$output" | jq -e '(.mints | length) > 0 and all(.mints[]; startswith("https://"))' >/dev/null
}
@test "the seller gateway is not reachable from the main listener" {
on_node || skip "loopback listener"
run curl -s -o /dev/null -w '%{http_code}' -m 5 http://127.0.0.1:5678/routstr/v1/models
[ "$output" = "404" ]
run curl -s -o /dev/null -w '%{http_code}' -m 5 http://127.0.0.1:5678/routstr/admin
[ "$output" = "404" ]
}
@test "llama-server publishes no host port" {
run rpc_result container-list
[ "$status" -eq 0 ]
echo "$output" | jq -e '.[] | select(.name == "llama-server") | (.lan_address // null) == null' >/dev/null
}
@test "selling status reports what routstr holds for payout" {
run payout_status
[ "$status" -eq 0 ]
echo "$output" | jq -e '.value.payout_error == null and .value.payout.payout_minimum_sats > 0' >/dev/null
}
# ────────────────────────────────────────────────────────────────────
# Destructive tier (stop → start → restart)
# ────────────────────────────────────────────────────────────────────
@test "routstr stop → start keeps the price and the held earnings" {
[[ "${ARCHY_ALLOW_DESTRUCTIVE:-0}" == "1" ]] || skip "ARCHY_ALLOW_DESTRUCTIVE not set"
on_node || skip "catalog is host-local"
local before_price before_held
before_price=$(catalog_entry | jq -c '.pricing | {prompt, completion}')
before_held=$(held_sats)
rpc_call package.stop '{"id":"routstr"}' >/dev/null 2>&1 || true
run wait_for_container_status routstr stopped 90
[ "$status" -eq 0 ]
rpc_call package.start '{"id":"routstr"}' >/dev/null 2>&1 || true
run wait_for_container_status routstr running 120
[ "$status" -eq 0 ]
[ "$(catalog_entry | jq -c '.pricing | {prompt, completion}')" = "$before_price" ]
[ "$(held_sats)" -ge "$before_held" ]
}
@test "routstr restart serves the catalog again" {
[[ "${ARCHY_ALLOW_DESTRUCTIVE:-0}" == "1" ]] || skip "ARCHY_ALLOW_DESTRUCTIVE not set"
on_node || skip "catalog is host-local"
run rpc_result package.restart '{"id":"routstr"}'
[ "$status" -eq 0 ]
run wait_for_container_status routstr running 120
[ "$status" -eq 0 ]
run catalog_entry
[ "$status" -eq 0 ]
}
@test "llama-server stop → start → restart returns to running" {
[[ "${ARCHY_ALLOW_DESTRUCTIVE:-0}" == "1" ]] || skip "ARCHY_ALLOW_DESTRUCTIVE not set"
rpc_call package.stop '{"id":"llama-server"}' >/dev/null 2>&1 || true
run wait_for_container_status llama-server stopped 90
[ "$status" -eq 0 ]
rpc_call package.start '{"id":"llama-server"}' >/dev/null 2>&1 || true
run wait_for_container_status llama-server running 180
[ "$status" -eq 0 ]
run rpc_result package.restart '{"id":"llama-server"}'
[ "$status" -eq 0 ]
run wait_for_container_status llama-server running 180
[ "$status" -eq 0 ]
}
# ────────────────────────────────────────────────────────────────────
# Cascade tier (update, uninstall with preserve_data, reinstall)
# ────────────────────────────────────────────────────────────────────
@test "routstr package.update keeps the held earnings" {
[[ "${ARCHY_ALLOW_CASCADE_DESTRUCTIVE:-0}" == "1" ]] || skip "ARCHY_ALLOW_CASCADE_DESTRUCTIVE not set"
local before_held
before_held=$(held_sats)
run rpc_result package.update '{"id":"routstr"}'
[ "$status" -eq 0 ]
run wait_for_container_status routstr running 180
[ "$status" -eq 0 ]
on_node && catalog_entry >/dev/null
[ "$(held_sats)" -ge "$before_held" ]
}
@test "routstr uninstall (preserve_data) → reinstall keeps the held earnings" {
[[ "${ARCHY_ALLOW_CASCADE_DESTRUCTIVE:-0}" == "1" ]] || skip "ARCHY_ALLOW_CASCADE_DESTRUCTIVE not set"
local before_held
before_held=$(held_sats)
run rpc_result package.uninstall '{"id":"routstr","preserve_data":true}'
[ "$status" -eq 0 ]
run wait_for_container_status routstr absent 120
[ "$status" -eq 0 ]
run rpc_result package.install "{\"id\":\"routstr\",\"dockerImage\":\"${ROUTSTR_IMAGE}\"}"
[ "$status" -eq 0 ]
run wait_for_container_status routstr running 180
[ "$status" -eq 0 ]
on_node && catalog_entry >/dev/null
[ "$(held_sats)" -ge "$before_held" ]
}