signal-bot/scripts/e2e.sh
Krzysztof kuhy Rudnicki 64852ae578
Some checks failed
CI / check (push) Has been cancelled
CI / web (push) Has been cancelled
fix: e2e.sh never sourced its own proofs, so none of them ran
Splitting e2e_proofs.sh out at the 250-line cap took the source line with
it. e2e.sh still called prove_the_add_photo_invariants, bash still reported
"command not found", and the script still exited 127 -- after every scenario
had already printed ok, which is why it read as a passing run with a stray
message at the end. CI has been red since 08599e6 for exactly this.

Six assertions were silently not running: that every stored file was
stripped, that a typed label never became a key in custom.json, that a
!dodaj carrying no attachment minted nothing, that refused uploads were not
stored, that signal-cli own copies were deleted, and that the adder was
recorded. All six pass now that they execute.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4QTp3h5Q3MvfrwGV8xqPf
2026-08-31 23:23:38 +02:00

187 lines
7.4 KiB
Bash
Executable File

#!/bin/bash
# ============================================================================
# End-to-end test: the real bot against the fake Signal server.
#
# Boots the protocol-faithful fake, boots the unmodified bot pointed at it,
# replays every scenario and exits non-zero on the first mismatch. No Signal
# account, no network, no container.
# ============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly SCRIPT_DIR
REPO_ROOT="$(dirname "$SCRIPT_DIR")"
# The post-replay assertions, split out at the 250-line cap. Sourced
# rather than executed, because they run against the same fake server and
# the same bot process the scenarios used -- and because the split lost
# this line once already, which left every one of them silently unrun and
# the whole script exiting 127.
# shellcheck source=scripts/e2e_proofs.sh
source "$SCRIPT_DIR/e2e_proofs.sh"
readonly REPO_ROOT
readonly PYTHON="${PYTHON:-$REPO_ROOT/.venv/bin/python}"
readonly FAKE_PORT="${FAKE_PORT:-8099}"
readonly BOT_PORT="${BOT_PORT:-8098}"
readonly FAKE_URL="http://127.0.0.1:${FAKE_PORT}"
# Both spellings of one group, exactly as the real API reports them:
# /v1/groups returns the `group.<base64>` form (what .env holds), while an
# incoming envelope carries the id that base64 decodes to. Modelling only
# one is why the offline suite once passed against a bot that ignored every
# message in its own group.
readonly WATCHED_GROUP="group.WlRKbExYZGhkR05vWldRdFozSnZkWEF0YVdRdE16SXRZbmwwWlhNaElTRT0="
readonly WATCHED_GROUP_INTERNAL="ZTJlLXdhdGNoZWQtZ3JvdXAtaWQtMzItYnl0ZXMhISE="
# A second watched group, so the suite proves replies and rate limits stay
# in the group that asked instead of leaking into the other one.
readonly SECOND_GROUP="group.WlRKbExYTmxZMjl1WkMxbmNtOTFjQzFwWkMwek1pMWllWFJsY3lFaElTRT0="
readonly SECOND_GROUP_INTERNAL="ZTJlLXNlY29uZC1ncm91cC1pZC0zMi1ieXRlcyEhISE="
# Not seeded at boot: these arrive mid-run, which is the point.
readonly THIRD_GROUP="group.WlRKbExYUm9hWEprTFdkeWIzVndMV2xrTFRNeUxXSjVkR1Z6SVNFaElRPT0="
readonly THIRD_GROUP_INTERNAL="ZTJlLXRoaXJkLWdyb3VwLWlkLTMyLWJ5dGVzISEhIQ=="
readonly FOURTH_GROUP="group.WlRKbExXWnZkWEowYUMxbmNtOTFjQzFwWkMwek1pMWllWFJsY3lFaElTRT0="
readonly FOURTH_GROUP_INTERNAL="ZTJlLWZvdXJ0aC1ncm91cC1pZC0zMi1ieXRlcyEhISE="
readonly OTHER_GROUP="group.e2e.other=="
FAKE_PID=""
BOT_PID=""
TEMP_DIR=""
cleanup() {
# Plain ifs, not `A && B || C`: that is not if-then-else, and C runs when
# A is true but B fails -- which is exactly the case here (kill on an
# already-dead pid).
if [[ -n "$BOT_PID" ]]; then
kill "$BOT_PID" 2>/dev/null || true
fi
if [[ -n "$FAKE_PID" ]]; then
kill "$FAKE_PID" 2>/dev/null || true
fi
wait 2>/dev/null || true
if [[ -n "$TEMP_DIR" && -d "$TEMP_DIR" ]]; then
rm -rf "$TEMP_DIR"
fi
}
trap cleanup EXIT
die() {
echo "Error: $1" >&2
exit 1
}
wait_for_url() {
local url="$1" name="$2" attempt=0
until curl -sf "$url" >/dev/null 2>&1; do
attempt=$((attempt + 1))
if [[ $attempt -ge 100 ]]; then
echo "Error: $name did not come up at $url" >&2
exit 1
fi
sleep 0.1
done
}
wait_for_subscription() {
local attempt=0
until [[ "$(curl -sf "${FAKE_URL}/_control/ready" | tr -dc '0-9')" != "0" ]]; do
attempt=$((attempt + 1))
if [[ $attempt -ge 100 ]]; then
echo "Error: the bot never subscribed to the receive websocket" >&2
exit 1
fi
sleep 0.1
done
}
validate_requirements() {
if [[ ! -x "$PYTHON" ]]; then
echo "Error: no interpreter at $PYTHON. Run: uv venv .venv && uv pip install -e '.[dev]'" >&2
exit 1
fi
command -v curl >/dev/null || { echo "Error: curl is required" >&2; exit 1; }
}
main() {
validate_requirements
TEMP_DIR="$(mktemp -d)"
STARTED_AT="$(date +%s)"
echo "== booting the fake Signal server on :${FAKE_PORT}"
export FAKE_PORT
"$PYTHON" -m uvicorn fake_signal.server:app \
--host 127.0.0.1 --port "$FAKE_PORT" --log-level warning &
FAKE_PID=$!
wait_for_url "${FAKE_URL}/v1/about" "fake signal server"
echo "== seeding the fake with a registered, joined account"
curl -sf -X POST "${FAKE_URL}/_control/accounts" \
-H 'Content-Type: application/json' \
-d '["+15550000000"]' >/dev/null
curl -sf -X POST "${FAKE_URL}/_control/groups" \
-H 'Content-Type: application/json' \
-d "[{\"id\":\"${WATCHED_GROUP}\",\"internal_id\":\"${WATCHED_GROUP_INTERNAL}\",\"name\":\"e2e\",\"member\":true,\"joinable\":true},{\"id\":\"${SECOND_GROUP}\",\"internal_id\":\"${SECOND_GROUP_INTERNAL}\",\"name\":\"e2e-second\",\"member\":true,\"joinable\":true}]" >/dev/null
# GROUP_IDS and TRUSTED_INVITERS deliberately empty: the bot discovers
# its groups from the account, which is the configuration real
# deployments use. Set rather than merely unset, because load_dotenv()
# would otherwise let the developer's own .env decide what this run
# watches -- and a TRUSTED_INVITERS there would refuse every fixture
# group. The gate itself is covered in tests/test_trust.py.
# The album is private and lives outside this repository, so the run
# builds a synthetic one: three invented people, a combination and a
# video, scoped to the watched group so the scoping scenario can prove
# it does not exist in the second one.
echo "== writing a synthetic photo pack"
"$PYTHON" "$REPO_ROOT/tool/e2e_pack.py" --dir "${TEMP_DIR}/pack" --group "$WATCHED_GROUP"
echo "== booting the bot on :${BOT_PORT}, pointed at the fake"
env -u CAT_API \
PHONE_NUMBER="+15550000000" \
GROUP_IDS="" \
TRUSTED_INVITERS="" \
API_BASE_URL="$FAKE_URL" \
DATABASE_PATH="${TEMP_DIR}/e2e.sqlite3" \
CAT_API_URL="${FAKE_URL}/_fixtures/cat-api" \
DOG_API_URL="${FAKE_URL}/_fixtures/dog-api" \
GECKO_API_URL="${FAKE_URL}/_fixtures/gecko-api" \
COMMONS_API_URL="${FAKE_URL}/_fixtures/commons-api" \
POEM_TEXT_URL="${FAKE_URL}/_fixtures/poem-text?slug={slug}" \
PORTRAIT_CACHE_DIR="${TEMP_DIR}/portraits" \
PHOTO_PACK_DIR="${TEMP_DIR}/pack" \
BIND_PORT="$BOT_PORT" \
SILENCE_SECONDS=0 \
LIVENESS_TICK_SECONDS=1 \
"$PYTHON" -m signal_bot &
BOT_PID=$!
wait_for_url "http://127.0.0.1:${BOT_PORT}/healthz" "bot"
wait_for_subscription
echo "== generating media for !add_photo (real exif, never committed)"
"$PYTHON" "$REPO_ROOT/tool/e2e_media.py" --upload "$FAKE_URL"
echo "== replaying scenarios"
"$PYTHON" -m fake_signal.cli \
"$REPO_ROOT"/fake_signal/scenarios/*.yaml \
--settle "${E2E_SETTLE:-0.15}" \
--base-url "$FAKE_URL" \
--group "watched=${WATCHED_GROUP_INTERNAL}" \
--group "reply=${WATCHED_GROUP}" \
--group "second=${SECOND_GROUP_INTERNAL}" \
--group "second_reply=${SECOND_GROUP}" \
--group "third=${THIRD_GROUP_INTERNAL}" \
--group "third_reply=${THIRD_GROUP}" \
--group "fourth=${FOURTH_GROUP_INTERNAL}" \
--group "fourth_reply=${FOURTH_GROUP}" \
--group "other=${OTHER_GROUP}"
prove_the_add_photo_invariants
prove_the_deafness_gate
enforce_the_time_budget "$STARTED_AT"
}
# The three claims a reply cannot make on its own: nothing was written when
# nothing was stored, nothing kept its metadata, and no second copy survived.
main "$@"