smolboid/tests/rig.sh

194 lines
7.2 KiB
Bash
Executable file

#!/usr/bin/env bash
# smolboid integration rig (PLAN.md step 7): a local bunshin, the fumi CLI as
# the author, and the daemon under test — covering post, command, repost,
# timestamp collision, size-cap warning, both prune modes and an author
# rotation. Real binaries, real Noise sessions, real envelopes.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
FUMI_DIR="$ROOT/../fumi"
BUNSHIN_DIR="$ROOT/../bunshin"
RIG="$(mktemp -d /tmp/smolboid-rig.XXXXXX)"
HOST=127.0.0.1
SMTP_PORT=19611
HTTP_PORT=18081
DAEMON_ADDR="twtxt@$HOST:$SMTP_PORT"
AUTHOR_ADDR="tobias@$HOST:$SMTP_PORT"
DATA="$RIG/data"
BUNSHIN_PID=""
SMOLBOID_PID=""
cleanup() {
[[ -n "$BUNSHIN_PID" ]] && kill "$BUNSHIN_PID" 2>/dev/null || true
[[ -n "$SMOLBOID_PID" ]] && kill "$SMOLBOID_PID" 2>/dev/null || true
}
trap cleanup EXIT
step() { printf '\n== %s ==\n' "$*"; }
fail() { echo "FAIL: $*" >&2; exit 1; }
wait_for() { # file, text, [tries]
local file=$1 text=$2 tries=${3:-20} i
for ((i = 0; i < tries; i++)); do
[[ -f $file ]] && grep -q "$text" "$file" && return 0
sleep 1
done
fail "timed out waiting for '$text' in $file"
}
wait_gone() { # file, text, [tries]
local file=$1 text=$2 tries=${3:-20} i
for ((i = 0; i < tries; i++)); do
grep -q "$text" "$file" || return 0
sleep 1
done
fail "'$text' is still in $file"
}
step "build bunshin, fumi, smolboid"
cargo build --manifest-path "$BUNSHIN_DIR/Cargo.toml" --quiet
cargo build --manifest-path "$FUMI_DIR/Cargo.toml" --quiet
cargo build --manifest-path "$ROOT/Cargo.toml" --quiet
BUNSHIN="$BUNSHIN_DIR/target/debug/bunshin"
FUMI="$FUMI_DIR/target/debug/fumi"
SMOLBOID="$ROOT/target/debug/smolboid"
step "bunshin on $HOST:$SMTP_PORT"
"$BUNSHIN" keygen --key "$RIG/server.key" >"$RIG/keygen.log" 2>&1
SERVER_KEY="$(grep -oE '[a-z2-7]{52}' "$RIG/keygen.log" | head -1)"
[[ -n "$SERVER_KEY" ]] || { cat "$RIG/keygen.log"; fail "no server key"; }
"$BUNSHIN" serve --key "$RIG/server.key" --db "$RIG/server.db" \
--host "$HOST" --port "$SMTP_PORT" >"$RIG/bunshin.log" 2>&1 &
BUNSHIN_PID=$!
sleep 1
step "identities: author tobias, daemon twtxt"
mkdir -p "$DATA"
fumi() { "$FUMI" --key "$RIG/author.key" --db "$RIG/author.db" --timeout 10 "$@"; }
dfumi() { "$FUMI" --key "$RIG/master.key" --db "$DATA/mail.db" --timeout 10 "$@"; }
fumi keygen --force >/dev/null
fumi trust "$HOST:$SMTP_PORT" "$SERVER_KEY"
fumi register "$AUTHOR_ADDR" >/dev/null
"$SMOLBOID" keygen "$RIG/master.key" >/dev/null
dfumi trust "$HOST:$SMTP_PORT" "$SERVER_KEY"
dfumi register "$DAEMON_ADDR" >/dev/null
step "admit tobias"
cat >"$RIG/smolboid.toml" <<EOF
listen = $HOST:$HTTP_PORT
base_url = http://$HOST:$HTTP_PORT
smolmail_host = $HOST
mailbox = twtxt
master = $RIG/master.key
data = $DATA
poll_secs = 1
timeout = 10
cap_bytes = 1048576
EOF
"$SMOLBOID" admit --config "$RIG/smolboid.toml" "$AUTHOR_ADDR" >"$RIG/admit.log" 2>&1 \
|| { cat "$RIG/admit.log"; fail "admit"; }
grep -q "welcome mail sent" "$RIG/admit.log" \
|| { cat "$RIG/admit.log"; fail "no welcome mail"; }
# The Accept token's proof is the next step: without it, posts stay in the
# requests tier and nothing ever publishes.
fumi fetch >/dev/null
FEED="$DATA/feeds/tobias.txt"
step "serve"
"$SMOLBOID" serve --config "$RIG/smolboid.toml" >"$RIG/serve.log" 2>&1 &
SMOLBOID_PID=$!
sleep 2
step "post"
fumi send "$DAEMON_ADDR" --body "hello smolverse" >/dev/null
wait_for "$FEED" "hello smolverse"
"$SMOLBOID" check "$FEED" >"$RIG/check.log" || { cat "$RIG/check.log"; fail "check"; }
grep -q "1 verified, 0 unsigned, 0 invalid" "$RIG/check.log" \
|| { cat "$RIG/check.log"; fail "the first post did not verify"; }
curl -sf "http://$HOST:$HTTP_PORT/twtxt/tobias.txt" | grep -q "hello smolverse" \
|| fail "the feed is not served over HTTP"
[[ "$(curl -s -o /dev/null -w '%{http_code}' "http://$HOST:$HTTP_PORT/twtxt/nobody.txt")" == 404 ]] \
|| fail "a missing feed must 404"
step "commands: nick, repost"
fumi send "$DAEMON_ADDR" --header "Twt-Nick: toby" --body "" >/dev/null
wait_for "$FEED" "# nick = toby"
fumi send "$DAEMON_ADDR" \
--header "Twt-Repost: @<tobias http://$HOST:$HTTP_PORT/twtxt/tobias.txt> #abc1234" \
--body "" >/dev/null
wait_for "$FEED" "# smol:repost .* #abc1234"
"$SMOLBOID" check "$FEED" >"$RIG/check.log" || { cat "$RIG/check.log"; fail "check"; }
grep -q "2 verified, 0 unsigned, 0 invalid" "$RIG/check.log" \
|| { cat "$RIG/check.log"; fail "the repost did not verify"; }
step "delete"
TS="$(grep "hello smolverse" "$FEED" | cut -f1)"
fumi send "$DAEMON_ADDR" --header "Twt-Delete: $TS" --body "" >/dev/null
wait_gone "$FEED" "hello smolverse"
step "timestamp collision"
collided=0
for i in 1 2 3 4 5; do
fumi send "$DAEMON_ADDR" --body "collide A $i" >/dev/null &
A_PID=$!
fumi send "$DAEMON_ADDR" --body "collide B $i" >/dev/null &
B_PID=$!
# Only the two sends, never the servers.
wait "$A_PID" "$B_PID"
sleep 3
if grep -q "collide A $i" "$FEED" && grep -q "collide B $i" "$FEED"; then
continue # different seconds; try again
fi
fumi fetch >/dev/null
if fumi list --requests 2>/dev/null | grep -q "timestamp .* already exists"; then
collided=1
break
fi
done
[[ "$collided" == 1 ]] || fail "no collision rejection arrived after 5 attempts"
step "retention: hard limit"
kill "$SMOLBOID_PID" 2>/dev/null || true
# Metadata (~190B) plus one record (~253B) must fit under the cap, and land
# above its 90% mark so the warning fires.
sed -i 's/^cap_bytes = .*/cap_bytes = 460/' "$RIG/smolboid.toml"
"$SMOLBOID" serve --config "$RIG/smolboid.toml" >>"$RIG/serve.log" 2>&1 &
SMOLBOID_PID=$!
sleep 2
fumi send "$DAEMON_ADDR" --body "over the cap" >/dev/null
sleep 3
grep -q "over the cap" "$FEED" && fail "a capped feed published a record"
fumi fetch >/dev/null
fumi list --requests 2>/dev/null | grep -q "feed is at its size cap" \
|| fail "no cap rejection reply"
step "retention: fifo mode"
fumi send "$DAEMON_ADDR" --header "Twt-Prune: fifo" --body "" >/dev/null
wait_for "$DATA/state.json" '"prune": "Fifo"'
fumi send "$DAEMON_ADDR" --body "fifo post" >/dev/null
wait_for "$FEED" "fifo post"
[[ "$(grep -cE '^[0-9]' "$FEED")" == 1 ]] || fail "fifo did not prune down to one record"
fumi fetch >/dev/null
fumi list --requests 2>/dev/null | grep -q "90% of its size cap" \
|| fail "no 90% warning reply"
"$SMOLBOID" check "$FEED" >"$RIG/check.log" || { cat "$RIG/check.log"; fail "check"; }
grep -q "1 verified, 0 unsigned, 0 invalid" "$RIG/check.log" \
|| { cat "$RIG/check.log"; fail "the pruned feed does not verify"; }
step "author rotation"
fumi rotate >/dev/null
fumi send "$DAEMON_ADDR" --body "after rotation" >/dev/null
wait_for "$FEED" "after rotation"
OWNERS="$(python3 -c "import json;print(len(json.load(open('$DATA/state.json'))['feeds'][0]['owner_keys']))")"
[[ "$OWNERS" == 2 ]] || fail "expected 2 owner keys after rotation, got $OWNERS"
"$SMOLBOID" check "$FEED" >"$RIG/check.log" || { cat "$RIG/check.log"; fail "check"; }
# Under the fifo cap only one record fits, so the rotation post pruned the
# fifo post; what remains must fully verify.
grep -q "1 verified, 0 unsigned, 0 invalid" "$RIG/check.log" \
|| { cat "$RIG/check.log"; fail "rotation-era records do not verify"; }
step "rig passed"
echo "feeds: $FEED"
echo "logs: $RIG"
cleanup
trap - EXIT