#!/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")" 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.` 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)" 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" \ PORTRAIT_CACHE_DIR="${TEMP_DIR}/portraits" \ PHOTO_PACK_DIR="${TEMP_DIR}/pack" \ BIND_PORT="$BOT_PORT" \ SILENCE_SECONDS=0 \ "$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 \ --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 } # 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. prove_the_add_photo_invariants() { local pack="${TEMP_DIR}/pack" echo "== checking every stored file was actually stripped" "$PYTHON" "$REPO_ROOT/tool/e2e_media.py" --verify-pack "$pack" echo "== checking a typed label never reached custom.json as a key" if grep -qE '"(wakacje|piesek)"[[:space:]]*:' "${pack}/custom.json"; then echo "Error: a typed label became a key in custom.json" >&2 exit 1 fi echo "== checking !dodaj with no attachment minted nothing" if grep -q "piesek" "${pack}/custom.json"; then echo "Error: a mint survived a message that carried no attachment" >&2 exit 1 fi echo "== checking the refused uploads were not stored" if grep -q "att-junk" "${pack}/tags.json"; then echo "Error: a refused file reached tags.json" >&2 exit 1 fi echo "== checking signal-cli's own copies were deleted" local left left="$(curl -sf "${FAKE_URL}/_control/attachments")" if [[ "$left" == *"att-person"* || "$left" == *"att-custom"* ]]; then echo "Error: an ingested attachment is still in the container store: ${left}" >&2 exit 1 fi echo "== checking the adder was recorded for a future !undo_photo" if [[ ! -s "${pack}/adds.json" ]]; then echo "Error: adds.json was never written" >&2 exit 1 fi } # The gate that did not exist on 2026-08-29, proven against the real process: # with signal-cli unable to reach Signal, /healthz must go red rather than # keep answering 200 while nothing arrives. prove_the_deafness_gate() { local health="http://127.0.0.1:${BOT_PORT}/healthz" echo "== cutting the fake's connection to Signal" curl -sf -X POST "${FAKE_URL}/_control/connection" \ -H 'Content-Type: application/json' -d '{"alive": false}' >/dev/null local code="" waited=0 while ((waited < 150)); do code="$(curl -s -o /dev/null -w '%{http_code}' "$health")" [[ "$code" == "503" ]] && break sleep 1 waited=$((waited + 1)) done [[ "$code" == "503" ]] || die "/healthz still answers ${code} with the connection cut" curl -s "$health" | grep -q "Closed unexpectedly" || die "/healthz went red without saying why" echo "== /healthz turned 503 and named the reason after ${waited}s" echo "== restoring the connection" curl -sf -X POST "${FAKE_URL}/_control/connection" \ -H 'Content-Type: application/json' -d '{"alive": true}' >/dev/null waited=0 while ((waited < 150)); do code="$(curl -s -o /dev/null -w '%{http_code}' "$health")" [[ "$code" == "200" ]] && break sleep 1 waited=$((waited + 1)) done [[ "$code" == "200" ]] || die "/healthz stayed ${code} after the connection came back" echo "== /healthz recovered on its own after ${waited}s" } main "$@"