mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 17:57:02 +01:00
A photo sent to the chat no longer enters the album by itself. pack_add strips it, checks it against the album and the queue, and writes it to pending/ with a sidecar naming the people and labels the sender proposed; the reply is "czeka na zatwierdzenie". The management page lists the queue above the tagger, with the proposal pre-ticked, and Approve moves the file in, mints the labels, records approved_at and asks the bot to rebuild its table through /_reload -- the same publish path every other data change takes, so PackHolder.reload is gone. A reload the bot refuses is reported on the page, not rolled back: the photo is in the album either way. The personal-data gate now reads pending/*.json too, so a label that is still waiting for approval is forbidden in the tree already. The e2e run boots the management page beside the bot with the same environment, proves the queue is stripped and never reaches media/, approves it through the real routes, and has !losowe answer with the photo afterwards without a restart; exiftool is found through media_strip.locate() there rather than PATH, which lacks vendor_perl on Arch. !gadaj is dropped from !odcisz: it was within a typo of !dodaj, and the nearest-command suggestion must never be a coin toss. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
219 lines
8.9 KiB
Bash
Executable File
219 lines
8.9 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 MANAGE_PORT="${MANAGE_PORT:-8097}"
|
|
readonly FAKE_URL="http://127.0.0.1:${FAKE_PORT}"
|
|
readonly MANAGE_URL="http://127.0.0.1:${MANAGE_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=""
|
|
MANAGE_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 "$MANAGE_PID" ]]; then
|
|
kill "$MANAGE_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
|
|
}
|
|
|
|
# Drive the fake through these scenario files, against the bot booted above.
|
|
# One place for the group map, so a proof that injects after the main replay
|
|
# resolves the same symbolic names the scenarios do.
|
|
replay() {
|
|
"$PYTHON" -m fake_signal.cli "$@" \
|
|
--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}"
|
|
}
|
|
|
|
# The bot and the management page under one environment: the page approves
|
|
# into the album the bot reads, and asks the bot at BIND_PORT to rebuild its
|
|
# table -- a path that differed between them would prove nothing.
|
|
# 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.
|
|
# exec, because this is backgrounded: without it $! is a subshell, and the
|
|
# kill in cleanup would leave the real process running after the run.
|
|
with_bot_env() {
|
|
exec 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" \
|
|
CUSTOM_ALIASES_FILE="${TEMP_DIR}/aliases_custom.json" \
|
|
FOOD_LOG_FILE="${TEMP_DIR}/logs/food_log.json" \
|
|
WORKOUT_LOG_FILE="${TEMP_DIR}/logs/workout_log.json" \
|
|
BIND_PORT="$BOT_PORT" \
|
|
SILENCE_SECONDS=0 \
|
|
LIVENESS_TICK_SECONDS=1 \
|
|
"$@"
|
|
}
|
|
|
|
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
|
|
|
|
# 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"
|
|
# The food and workout logs are the operator's day, kept by other
|
|
# programs; the run gets one invented entry of each, dated today.
|
|
"$PYTHON" "$REPO_ROOT/tool/e2e_logs.py" --dir "${TEMP_DIR}/logs"
|
|
echo "== generating media for !dodaj (real exif, never committed)"
|
|
"$PYTHON" "$REPO_ROOT/tool/e2e_media.py" --upload "$FAKE_URL"
|
|
|
|
echo "== booting the bot on :${BOT_PORT}, pointed at the fake"
|
|
with_bot_env "$PYTHON" -m signal_bot &
|
|
BOT_PID=$!
|
|
echo "== booting the management page on :${MANAGE_PORT}, pointed at the bot"
|
|
with_bot_env "$PYTHON" "$REPO_ROOT/tool/web.py" \
|
|
--dir "${TEMP_DIR}/pack" --port "$MANAGE_PORT" --no-open &
|
|
MANAGE_PID=$!
|
|
wait_for_url "http://127.0.0.1:${BOT_PORT}/healthz" "bot"
|
|
wait_for_url "${MANAGE_URL}/api/album/pending" "management page"
|
|
wait_for_subscription
|
|
|
|
echo "== replaying scenarios"
|
|
replay "$REPO_ROOT"/fake_signal/scenarios/*.yaml
|
|
|
|
prove_the_add_photo_queue
|
|
prove_the_table_reloads_without_a_restart
|
|
prove_a_switch_is_live_without_a_reload
|
|
prove_an_alias_is_live_after_a_reload
|
|
prove_the_deafness_gate
|
|
enforce_the_time_budget "$STARTED_AT"
|
|
}
|
|
|
|
main "$@"
|