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>
202 lines
8.2 KiB
Bash
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" ]
|
|
}
|