mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 20:37:03 +01:00
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
193 lines
7.4 KiB
Bash
Executable File
193 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")"
|
|
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)"
|
|
|
|
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.
|
|
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" \
|
|
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 "== 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_deafness_gate
|
|
}
|
|
|
|
# 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 "$@"
|