smolboid/tests/rig.sh
randogoth 72c353cb5a docs: rewrite the author-facing copy in plain language
The copy was written for someone who had read the spec. It named UTF-8, RFC
3339, canonical form, code points and frontmatter fields, and the one place it
gave instructions it gave them as fumi command lines, which say nothing to
anyone using carrier or gsmol.

Now it uses the words the clients put on screen: "header", with the "+ header"
button both of them have, and "Requests folder", which is what both call it.
One word per idea throughout — post, feed, header, Requests folder — and
reasons are whole sentences that say what happened and what to do, so the
reply no longer opens "Reason:" followed by a lowercase fragment.

The first-contact mail is the one a stranger reads before anything else, so it
now covers accepting this address: a recommendation, not the mechanism behind
it. The mechanism would also have aged badly, since a client that delivers its
token once makes accepting cost a single refused post rather than all of them.

A refused post gets a detail paragraph where one line cannot hold the fix. It
tells the author their token arrived and to send again, and what to do if it
is refused a second time, which is what happens on a client that attaches the
token to everything. A first post on an empty feed says so differently: the
author is watching their first attempt fail and needs to hear the feed is
fine.

Seven admin strings were built with format! and never reached the catalogue,
so an ack opened its body in lowercase and could not be translated. They are
entries now, and say what blocking does rather than naming the mechanism.

The cap warning said "90% of its 1048576 byte limit". It says 1 MB.

Four rig assertions matched the old wording and now match the new. They have
to read rendered mail, so they stay coupled to it in a way the unit tests,
which assert on Reason variants, do not.
2026-10-15 09:07:24 +03:00

332 lines
14 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, an author rotation,
# self-service registration by mail and the admin commands. 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
registration = open
admins = $AUTHOR_ADDR
admin_notify = on
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 "already has a post at"; 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 "Your feed is full" \
|| 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 "your feed is nearly full" \
|| 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"; }
# ---------------------------------------------------------------------------
# Self-service registration. Nothing below calls `smolboid admit`.
# ---------------------------------------------------------------------------
step "self-service: an unknown sender is told how to register"
dfumi() { "$FUMI" --key "$RIG/master.key" --db "$DATA/mail.db" --timeout 10 "$@"; }
dana() { "$FUMI" --key "$RIG/dana.key" --db "$RIG/dana.db" --timeout 10 "$@"; }
DANA_ADDR="dana@$HOST:$SMTP_PORT"
dana keygen --force >/dev/null
dana trust "$HOST:$SMTP_PORT" "$SERVER_KEY"
dana register "$DANA_ADDR" >/dev/null
dana send "$DAEMON_ADDR" --body "is anyone there" >/dev/null
# The info mail lands in dana's requests tier: she has not accepted the daemon
# yet, so it holds no token of hers.
for ((i = 0; i < 25; i++)); do
dana fetch >/dev/null 2>&1 || true
[[ "$(dana list --requests 2>/dev/null || true)" == *"how to get a feed"* ]] && break
sleep 1
done
[[ "$(dana list --requests 2>/dev/null || true)" == *"how to get a feed"* ]] \
|| { tail -20 "$RIG/serve.log"; fail "no info mail for an unknown sender"; }
# Nothing was published and no feed exists yet.
[[ ! -f "$DATA/feeds/dana.txt" ]] || fail "an unregistered sender got a feed"
step "self-service: Twt-Register creates the feed and hands over the token"
# Deliberately *not* `dana accept "$DAEMON_ADDR"`. Accepting it would make her
# client attach an Accept token to every later mail, and a published record
# cannot carry one and still verify, so her posts would all be refused. Her
# replies arrive in the requests tier instead.
dana send "$DAEMON_ADDR" --header "Twt-Register: dee" --body "" >/dev/null
wait_for "$DATA/feeds/dana.txt" "# nick = dee" 25
curl -fsS "http://$HOST:$HTTP_PORT/twtxt/dana.txt" >"$RIG/dana-feed.txt" \
|| fail "dana's feed is not served"
grep -q "# nick = dee" "$RIG/dana-feed.txt" || fail "nick not in the served feed"
# The confirmation carries the daemon's accept token; fetching it is what lets
# her publish, and her client learns the token from that mail.
for ((i = 0; i < 25; i++)); do
dana fetch >/dev/null 2>&1 || true
[[ "$(dana list --requests 2>/dev/null || true)" == *"your feed is ready"* ]] && break
sleep 1
done
[[ "$(dana list --requests 2>/dev/null || true)" == *"your feed is ready"* ]] \
|| { dana list --requests || true; fail "no registration confirmation"; }
step "self-service: a registered author can post"
dana send "$DAEMON_ADDR" --body "dana registered herself" >/dev/null
wait_for "$DATA/feeds/dana.txt" "dana registered herself" 25
"$SMOLBOID" check "$DATA/feeds/dana.txt" >"$RIG/dana-check.log" \
|| { cat "$RIG/dana-check.log"; fail "dana's records do not verify"; }
step "self-service: the admin was notified"
for ((i = 0; i < 20; i++)); do
fumi fetch >/dev/null 2>&1 || true
[[ "$(fumi list --requests 2>/dev/null || true)" == *"new feed dana"* ]] && break
sleep 1
done
[[ "$(fumi list --requests 2>/dev/null || true)" == *"new feed dana"* ]] \
|| { fumi list --requests || true; fail "no admin notification for the new feed"; }
step "Twt-Help returns the command reference"
dana send "$DAEMON_ADDR" --header "Twt-Help:" --body "" >/dev/null
for ((i = 0; i < 25; i++)); do
dana fetch >/dev/null 2>&1 || true
[[ "$(dana list --requests 2>/dev/null || true)" == *"your commands"* ]] && break
sleep 1
done
[[ "$(dana list --requests 2>/dev/null || true)" == *"your commands"* ]] \
|| fail "no help reply"
step "an accepted mailbox makes posts unpublishable, and says so"
# The trap the info mail warns about: once dana accepts this address her client
# attaches a token to every mail, and such a record could never be verified.
# The daemon must refuse it with that specific reason rather than the generic
# "resend the bare text".
dana accept "$DAEMON_ADDR" >/dev/null
dana send "$DAEMON_ADDR" --body "this carries a token" >/dev/null
sleep 4
grep -q "this carries a token" "$DATA/feeds/dana.txt" \
&& fail "a post carrying an accept token was published"
# Checked in both tiers on purpose: accepting the daemon is what moves its
# replies from her requests tier into her inbox, so this one arrives in the
# main tier precisely because of the step that broke her posting.
#
# Captured into a variable rather than piped into `grep -q`. Under `set -o
# pipefail`, grep -q exits on its first match and SIGPIPEs the producer, so a
# pipeline can report failure for a subject that is plainly there.
dana_mail() { dana list 2>/dev/null || true; dana list --requests 2>/dev/null || true; }
for ((i = 0; i < 20; i++)); do
dana fetch >/dev/null 2>&1 || true
MAIL="$(dana_mail)"
[[ "$MAIL" == *"accept token"* ]] && break
sleep 1
done
MAIL="$(dana_mail)"
[[ "$MAIL" == *"accept token"* ]] \
|| { echo "$MAIL"; fail "no explanation for the refused post"; }
# The whole sentence must survive as a subject: a line break in a mail header
# makes the block malformed and strips the subject entirely.
[[ "$MAIL" == *"your client attached"* ]] \
|| { echo "$MAIL"; fail "the reason was truncated in the subject"; }
dana block "$DAEMON_ADDR" >/dev/null
step "admin by mail: Twt-Block stops a feed"
fumi send "$DAEMON_ADDR" --header "Twt-Block: $DANA_ADDR" --body "" >/dev/null
sleep 3
dana send "$DAEMON_ADDR" --body "should not publish" >/dev/null
sleep 4
grep -q "should not publish" "$DATA/feeds/dana.txt" \
&& fail "a blocked author still published"
step "admin by mail: Twt-Registration closes the door"
fumi send "$DAEMON_ADDR" --header "Twt-Registration: closed" --body "" >/dev/null
sleep 3
MODE="$(python3 -c "import json;print(json.load(open('$DATA/state.json')).get('registration'))")"
[[ "$MODE" == "closed" ]] || fail "registration mode is $MODE, expected closed"
erin() { "$FUMI" --key "$RIG/erin.key" --db "$RIG/erin.db" --timeout 10 "$@"; }
ERIN_ADDR="erin@$HOST:$SMTP_PORT"
erin keygen --force >/dev/null
erin trust "$HOST:$SMTP_PORT" "$SERVER_KEY"
erin register "$ERIN_ADDR" >/dev/null
erin send "$DAEMON_ADDR" --header "Twt-Register:" --body "" >/dev/null
sleep 4
[[ ! -f "$DATA/feeds/erin.txt" ]] || fail "registration is closed but erin got a feed"
for ((i = 0; i < 20; i++)); do
erin fetch >/dev/null 2>&1 || true
[[ "$(erin list --requests 2>/dev/null || true)" == *"not taking new feeds"* ]] && break
sleep 1
done
[[ "$(erin list --requests 2>/dev/null || true)" == *"not taking new feeds"* ]] \
|| { erin list --requests || true; fail "erin was not told registration is closed"; }
step "rig passed"
echo "feeds: $FEED"
echo "logs: $RIG"
cleanup
trap - EXIT