#!/bin/sh # End-to-end HTTP tests against a running catcrafts-server. # # Why this exists separately from --selftest: that one covers the pure # functions (escaping, JSON, form validation) in-process. This covers the # things only a real request can show — status codes, headers, redirects, # form submission, and whether a page is actually complete without # JavaScript. Those are exactly the properties that matter at launch and the # ones a unit test cannot observe. # # Runs the server itself on a scratch port with a temporary orders file and the # FAKE payment rail, so it never touches real data, never dials Mollie, and needs # no setup. The fake rail makes the whole order lifecycle testable: it hands # out pretend payment links, and reports "paid" once the marker file exists — # which is how these tests simulate the customer paying. # # usage: tools/e2e.sh [path-to-catcrafts-server] # # Exits non-zero on the first failure, so it works as a CI gate. set -eu SERVER="${1:-}" if [ -z "$SERVER" ]; then SERVER=$(find bin -maxdepth 1 -type d -name 'Catcrafts.Server-*' | sort | head -n1)/catcrafts-server fi [ -x "$SERVER" ] || { echo "e2e: server binary not found or not executable: $SERVER" >&2; exit 1; } PORT="${E2E_PORT:-8199}" BASE="http://127.0.0.1:$PORT" # Where tools/fetch-media.sh put the mirrored files. # # NOT hardcoded to ./media: in the repo that is where they land, but CI points # the mirror at the persistent mount instead (/deploy-app/media) so the copies # survive a deploy. A check that assumed the dev layout reported every single # referenced file as missing on CI while the files were perfectly fine on the # mount — a failure that said "40 files missing" and meant "wrong directory". # Override with E2E_MEDIA_DIR; the workflow passes the same path it gave the # mirror. MEDIA_DIR="${E2E_MEDIA_DIR:-}" if [ -z "$MEDIA_DIR" ]; then for d in media /deploy-app/media; do if [ -d "$d" ]; then MEDIA_DIR="$d"; break; fi done fi WORK="$(mktemp -d)" ORDERS="$WORK/orders.jsonl" pass=0 fail=0 skipped=0 cleanup() { [ -n "${SRV_PID:-}" ] && kill "$SRV_PID" 2>/dev/null || true rm -rf "$WORK" } trap cleanup EXIT INT TERM # Deterministic environment: a developer shell that sourced the repo .env # must not leak real provider keys into the test server — live Sendcloud # rates would silently change the shipping totals asserted below. unset MOLLIE_API_KEY COINGATE_API_KEY SENDCLOUD_PUBLIC_KEY SENDCLOUD_SECRET_KEY SENDCLOUD_METHOD 2>/dev/null || true # An ephemeral GPG key so invoice signing runs the REAL signing path and the # suite can verify the signature. gpg is required (CI installs gnupg with the # base tools); a missing binary should fail loudly, not skip silently. export GNUPGHOME="$WORK/gnupg" mkdir -p "$GNUPGHOME"; chmod 700 "$GNUPGHOME" gpg --batch --passphrase '' --quick-gen-key 'Catcrafts e2e ' \ default default never >/dev/null 2>&1 \ || { echo "e2e: could not create a GPG key (is gnupg installed?)" >&2; exit 1; } export INVOICE_GPG_KEY='invoices@e2e.invalid' # A fake sendmail, so the mailer's REAL path — build the MIME message, attach # the signed invoice, shell out — runs with zero network. Each accepted # message lands as its own mail-.eml; the mailer sends sequentially from # one thread, so the count-up cannot race itself. cat > "$WORK/sendmail" < "$WORK/mail-\$n.eml" EOF chmod +x "$WORK/sendmail" export MAIL_COMMAND="$WORK/sendmail" export MAIL_FROM='Catcrafts ' # The bunq mutation callback. The secret IS the last segment of the callback # URL, and setting it is what brings the endpoint into existence — unset, the # path is an ordinary 404. Note what is NOT here: a bunq API key. One could # initiate payments, so no such key ever reaches the server; it only receives. export BUNQ_CALLBACK_SECRET='e2e-callback-secret-not-a-real-one' # The shipping rate table. Shipping has no compiled-in fallback any more — the # carrier table is the only source of prices — so without this file every # checkout correctly refuses and the whole order suite would be testing the # refusal path by accident. # # This is byte-for-byte the cache the daily Sendcloud refresh writes, so the # suite drives the production lookup with no test-only hook that could drift # from it: country -> [[maxWeightGrams, consumerCents], ...], prices already # VAT-inclusive (the gross-up happens at fetch, not at load). # # The single-unit rates are the €15 / €25 / €55 the totals below assert. The # second band exists so the too-heavy refusal has a real ceiling to hit: # 10 kg / 700 g per unit = 14 units per parcel. cat >"$ORDERS.shipping.json" <<'JSON' {"method":"e2e fixture","fetched_at":"2026-01-01T00:00:00Z","per_country":{ "NL":[[2000,1500],[10000,2900]], "DE":[[2000,2500],[10000,4200]], "GB":[[2000,5500],[10000,7900]]}} JSON # BOTH slots on the fake rail, so the suite covers the payment CHOICE as well # as the lifecycle: that the form offers it, that the picked rail is what gets # written to the ledger, and that an order polls the provider that issued its # link. Which rail is behind each slot is exactly the part these tests should # not care about — that is what makes the same assertions valid for Mollie and # CoinGate. Both fakes share one marker file, so touching it below settles # whichever orders are outstanding. "$SERVER" --serve "$PORT" --orders="$ORDERS" --rail=fake --crypto-rail=fake-crypto \ >"$WORK/server.log" 2>&1 & SRV_PID=$! # Wait for the listener rather than sleeping a fixed amount: a fixed sleep is # either too short on a loaded machine or wasted time on a fast one. i=0 while [ "$i" -lt 100 ]; do if curl -s -o /dev/null "$BASE/api/healthz" 2>/dev/null; then break; fi i=$((i + 1)) sleep 0.1 done if [ "$i" -ge 100 ]; then echo "e2e: server did not come up on $PORT" >&2 cat "$WORK/server.log" >&2 exit 1 fi ok() { pass=$((pass + 1)); printf ' ok %s\n' "$1"; } bad() { fail=$((fail + 1)); printf ' FAIL %s\n %s\n' "$1" "$2"; } # Counted and reported separately, never as a pass: a check that silently did not # run is how a suite ends up reporting green over an untested code path. skip() { skipped=$((skipped + 1)); printf ' SKIP %s\n %s\n' "$1" "$2"; } # status [method] [data] status() { _p="$1"; _want="$2"; _m="${3:-GET}"; _d="${4:-}" if [ "$_m" = POST ]; then _got=$(curl -s -o /dev/null -w '%{http_code}' -X POST -d "$_d" "$BASE$_p") elif [ "$_m" = HEAD ]; then # --head, not -X HEAD: with -X curl still waits for a response body # that a correct HEAD reply never sends, and hangs until timeout. _got=$(curl -s -o /dev/null -w '%{http_code}' --head "$BASE$_p") else _got=$(curl -s -o /dev/null -w '%{http_code}' -X "$_m" "$BASE$_p") fi [ "$_got" = "$_want" ] && ok "$_m $_p -> $_want" \ || bad "$_m $_p" "expected $_want, got $_got" } # body_has