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.
332 lines
14 KiB
Bash
Executable file
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
|