test_payment_backend_failure.sh (15803B)
1 #!/usr/bin/env bash 2 # Distinguish a merchant transport failure from an elapsed order timeout. 3 4 set -eu 5 6 function here() { 7 cd -- "$(dirname -- "$0")" && pwd 8 } 9 10 SRCDIR="${SRCDIR:-$(here)}" 11 BUILDDIR="${BUILDDIR:-$PWD}" 12 PAIVANA_HTTPD="${PAIVANA_HTTPD:-$BUILDDIR/../backend/paivana-httpd}" 13 PORT_BASE="${PAIVANA_PORT_BASE:-18400}" 14 MERCHANT_BACKEND_PORT=$((PORT_BASE + 120)) 15 PAIVANA_PORT=$((PORT_BASE + 121)) 16 SCRATCH="$(mktemp -d -t paivana-payment-failure.XXXXXX)" 17 MERCHANT_SOCKET="$SCRATCH/merchant.sock" 18 PUBLIC_MERCHANT_URL="https://public-merchant.example/instances/paivana/" 19 INTERNAL_MERCHANT_URL="http://merchant.invalid/instances/paivana/" 20 STUB_PID="" 21 PROXY_PID="" 22 PAIVANA_PID="" 23 24 function cleanup() { 25 set +e 26 [ -n "$STUB_PID" ] && kill -TERM "$STUB_PID" 2>/dev/null 27 [ -n "$PROXY_PID" ] && kill -TERM "$PROXY_PID" 2>/dev/null 28 [ -n "$PAIVANA_PID" ] && kill -TERM "$PAIVANA_PID" 2>/dev/null 29 wait "$STUB_PID" 2>/dev/null 30 wait "$PROXY_PID" 2>/dev/null 31 wait "$PAIVANA_PID" 2>/dev/null 32 if [ "${KEEP_TMP:-0}" = "1" ]; then 33 echo "Temp files kept in $SCRATCH" >&2 34 else 35 rm -rf "$SCRATCH" 36 fi 37 return 0 38 } 39 trap cleanup EXIT 40 41 function port_is_free() { 42 ! (exec 3<>"/dev/tcp/127.0.0.1/$1") 2>/dev/null 43 } 44 45 function wait_for_port() { 46 local port="$1" pid="$2" tries=50 47 while [ "$tries" -gt 0 ]; do 48 if ! port_is_free "$port"; then 49 return 0 50 fi 51 kill -0 "$pid" 2>/dev/null || return 1 52 sleep 0.1 53 tries=$((tries - 1)) 54 done 55 return 1 56 } 57 58 function wait_for_unix_socket() { 59 local path="$1" pid="$2" tries=50 60 while [ "$tries" -gt 0 ]; do 61 if [ -S "$path" ] && 62 grep -Fxq "READY $path" "$SCRATCH/proxy.log" 63 then 64 return 0 65 fi 66 kill -0 "$pid" 2>/dev/null || return 1 67 sleep 0.1 68 tries=$((tries - 1)) 69 done 70 return 1 71 } 72 73 function fail() { 74 echo "FAIL: $*" >&2 75 echo "==> paivana.log <==" >&2 76 tail -n 80 "$SCRATCH/paivana.log" >&2 || true 77 echo "==> merchant.log <==" >&2 78 tail -n 40 "$SCRATCH/merchant.log" >&2 || true 79 echo "==> proxy.log <==" >&2 80 tail -n 60 "$SCRATCH/proxy.log" >&2 || true 81 exit 1 82 } 83 84 function start_stub() { 85 PAIVANA_STUB_BASE_PATH=/instances/paivana \ 86 python3 "$SRCDIR/payment_backend_stub.py" "$MERCHANT_BACKEND_PORT" \ 87 >>"$SCRATCH/merchant.log" 2>&1 & 88 STUB_PID=$! 89 wait_for_port "$MERCHANT_BACKEND_PORT" "$STUB_PID" \ 90 || fail "merchant stub did not start" 91 } 92 93 function start_proxy() { 94 python3 "$SRCDIR/merchant_fault_proxy.py" \ 95 --listen-unix "$MERCHANT_SOCKET" \ 96 --upstream-port "$MERCHANT_BACKEND_PORT" \ 97 --fault-path /instances/paivana/private/orders/diagnostic-order \ 98 --fault-count 3 \ 99 >>"$SCRATCH/proxy.log" 2>&1 & 100 PROXY_PID=$! 101 wait_for_unix_socket "$MERCHANT_SOCKET" "$PROXY_PID" \ 102 || fail "merchant fault frontend did not start" 103 } 104 105 function redemption_body() { 106 local order="$1" 107 printf '{"order_id":"%s","website":"http://127.0.0.1:%u/article",' \ 108 "$order" "$PAIVANA_PORT" 109 printf '"expiration":{"t_s":2000000000},' 110 printf '"nonce":"000G40R40M30E209185GR38E1W"}' 111 } 112 113 function redeem() { 114 local order="$1" 115 curl -sS -o "$SCRATCH/response.json" -w '%{http_code} %{time_total}\n' \ 116 -H 'Content-Type: application/json' -X POST \ 117 "http://127.0.0.1:$PAIVANA_PORT/.well-known/paivana" \ 118 -d "$(redemption_body "$order")" 119 } 120 121 function expect_error() { 122 local want_code="$1" want_detail="$2" want_merchant_status="$3" 123 python3 - "$SCRATCH/response.json" "$want_code" "$want_detail" \ 124 "$want_merchant_status" <<'PY' 125 import json 126 import sys 127 128 with open(sys.argv[1], encoding="utf-8") as f: 129 body = json.load(f) 130 if body.get("code") != int(sys.argv[2]): 131 raise SystemExit(f"error code {body.get('code')}, want {sys.argv[2]}: {body}") 132 if body.get("detail") != sys.argv[3]: 133 raise SystemExit(f"detail {body.get('detail')!r}, want {sys.argv[3]!r}") 134 if body.get("merchant_http_status") != int(sys.argv[4]): 135 raise SystemExit( 136 f"merchant_http_status {body.get('merchant_http_status')!r}, " 137 f"want {sys.argv[4]}: {body}" 138 ) 139 PY 140 } 141 142 command -v curl >/dev/null 2>&1 || { echo "SKIP: curl not found"; exit 77; } 143 command -v python3 >/dev/null 2>&1 || { echo "SKIP: python3 not found"; exit 77; } 144 [ -x "$PAIVANA_HTTPD" ] || { echo "SKIP: paivana-httpd not found"; exit 77; } 145 if ! port_is_free "$MERCHANT_BACKEND_PORT" || \ 146 ! port_is_free "$PAIVANA_PORT"; then 147 echo "SKIP: test ports are occupied; change PAIVANA_PORT_BASE" >&2 148 exit 77 149 fi 150 151 mkdir -p "$SCRATCH/configd" "$SCRATCH/prefix/share/paivana/templates" 152 cp "$BUILDDIR/../frontend/paywall.en.must" \ 153 "$SCRATCH/prefix/share/paivana/templates/" 154 export PAIVANA_BASE_CONFIG="$SCRATCH/configd" 155 export PAIVANA_PREFIX="$SCRATCH/prefix/" 156 157 cat >"$SCRATCH/paivana.conf" <<EOF 158 [paivana] 159 DESTINATION_BASE_URL = http://127.0.0.1:9/ 160 BASE_URL = http://127.0.0.1:$PAIVANA_PORT/ 161 MERCHANT_BACKEND_URL = $PUBLIC_MERCHANT_URL 162 MERCHANT_BACKEND_INTERNAL_URL = $INTERNAL_MERCHANT_URL 163 MERCHANT_BACKEND_UNIX_PATH = $MERCHANT_SOCKET 164 MERCHANT_ACCESS_TOKEN = secret-token:stub 165 SECRET = payment-backend-failure-test 166 SERVE = tcp 167 PORT = $PAIVANA_PORT 168 BIND_TO = 127.0.0.1 169 # Small values make the payment-reservation boundary testable without a large 170 # load: two ordinary and two payment slots still satisfy the same accounting 171 # relationships as the 352+32 production defaults. 172 CONNECTION_LIMIT = 4 173 PAYMENT_CONNECTION_LIMIT = 2 174 EOF 175 176 function expect_invalid_internal_url() { 177 local value="$1" label="$2" 178 local cfg="$SCRATCH/invalid-internal.conf" 179 local log="$SCRATCH/invalid-internal.log" 180 181 sed "s|^MERCHANT_BACKEND_INTERNAL_URL =.*|MERCHANT_BACKEND_INTERNAL_URL = $value|" \ 182 "$SCRATCH/paivana.conf" > "$cfg" 183 if timeout 3 "$PAIVANA_HTTPD" -c "$cfg" -L WARNING >"$log" 2>&1; then 184 fail "Paivana accepted $label internal merchant URL" 185 fi 186 grep -q 'MERCHANT_BACKEND_INTERNAL_URL' "$log" || \ 187 fail "Paivana did not diagnose $label internal merchant URL" 188 } 189 190 expect_invalid_internal_url 'not-a-web-url/' malformed 191 expect_invalid_internal_url 'http://merchant.invalid/instances/paivana' \ 192 unterminated 193 194 start_stub 195 start_proxy 196 "$PAIVANA_HTTPD" -c "$SCRATCH/paivana.conf" -L DEBUG \ 197 >"$SCRATCH/paivana.log" 2>&1 & 198 PAIVANA_PID=$! 199 wait_for_port "$PAIVANA_PORT" "$PAIVANA_PID" || fail "paivana did not start" 200 grep -q 'GET /instances/paivana/private/templates ' \ 201 "$SCRATCH/merchant.log" || fail "template request lost merchant instance prefix" 202 grep -q 'GET /instances/paivana/private/templates/premium ' \ 203 "$SCRATCH/merchant.log" || fail "template detail lost merchant instance prefix" 204 205 # The server-side URL is deliberately unreachable over TCP and cleartext over 206 # the Unix socket, while every browser-facing value must retain public HTTPS. 207 status=$(curl -sS -D "$SCRATCH/paywall.headers" \ 208 -o "$SCRATCH/paywall.body" -w '%{http_code}' \ 209 "http://127.0.0.1:$PAIVANA_PORT/.well-known/paivana/templates/premium") 210 [ "$status" = 402 ] || fail "paywall page returned HTTP $status" 211 grep -qi '^Paivana: taler://pay-template/public-merchant.example/instances/paivana/premium' \ 212 "$SCRATCH/paywall.headers" || fail "paywall header did not use public URL" 213 grep -qi "connect-src 'self' https://public-merchant.example" \ 214 "$SCRATCH/paywall.headers" || fail "paywall CSP did not use public URL" 215 grep -q 'https://public-merchant.example/instances/paivana/' \ 216 "$SCRATCH/paywall.body" || fail "paywall body did not use public URL" 217 if grep -q 'merchant.invalid' "$SCRATCH/paywall.body"; then 218 fail "internal merchant URL leaked into paywall body" 219 fi 220 221 # Warm the merchant pool with an ordinary order lookup. Together with the 222 # startup template requests this leaves a known-good persistent connection in 223 # Paivana's merchant curl context, which is what the production process has 224 # between its infrequent genuine payment attempts. 225 read -r status elapsed < <(redeem warm-order) 226 [ "$status" = "404" ] || fail "merchant warm-up returned HTTP $status" 227 expect_error 9802 warm-order 404 || fail "unexpected warm-up response JSON" 228 [ "$(grep -c '/private/orders/warm-order' "$SCRATCH/merchant.log")" = 1 ] || \ 229 fail "merchant warm-up did not use exactly one request" 230 231 # The frontend has kept the merchant connection from warm-order alive. 232 # Let it become genuinely idle, then have the frontend reset the next three 233 # matching transport attempts before forwarding or access-logging them. The 234 # first fault must therefore be an idle reused TCP connection. libcurl retries 235 # that reused-connection failure internally, and Paivana retries the completed 236 # status-zero operation once more; the observed topology therefore requires 237 # three resets. A fresh request remains healthy once that window has passed. 238 sleep 0.3 239 read -r status elapsed < <(redeem diagnostic-order) 240 [ "$status" = "502" ] || fail "diagnostic trigger returned HTTP $status" 241 expect_error 9801 diagnostic-order 0 || fail "unexpected diagnostic-trigger JSON" 242 for _ in $(seq 1 50); do 243 grep -q 'Forced-fresh merchant diagnostic for order `diagnostic-order' \ 244 "$SCRATCH/paivana.log" && break 245 sleep 0.1 246 done 247 grep -q 'Forced-fresh merchant diagnostic for order `diagnostic-order.*completed HTTP status 404' \ 248 "$SCRATCH/paivana.log" || fail "successful fresh-connection diagnostic missing" 249 grep -q 'Forced-fresh merchant diagnostic for order `diagnostic-order.*new connections 1' \ 250 "$SCRATCH/paivana.log" || fail "fresh diagnostic did not report a new connection" 251 grep -q "retrying once within the original 5 s deadline" \ 252 "$SCRATCH/paivana.log" || fail "Paivana application retry diagnostic missing" 253 grep -q 'FAULT index=1 .*path=/instances/paivana/private/orders/diagnostic-order reused=yes' \ 254 "$SCRATCH/proxy.log" || fail "first fault did not use the idle merchant connection" 255 [ "$(grep -c 'FAULT .*path=/instances/paivana/private/orders/diagnostic-order' "$SCRATCH/proxy.log")" = 3 ] || \ 256 fail "frontend did not cover libcurl and Paivana retry attempts" 257 [ "$(grep -c '/private/orders/diagnostic-order' "$SCRATCH/merchant.log")" = 1 ] || \ 258 fail "merchant saw a failed order request or missed the fresh diagnostic" 259 [ "$(grep -c 'ACCESS .*path=/instances/paivana/private/orders/diagnostic-order' "$SCRATCH/proxy.log")" = 1 ] || \ 260 fail "frontend access log contains a failed order request" 261 if grep -q 'secret-token:stub' "$SCRATCH/paivana.log"; then 262 fail "merchant bearer token leaked into Paivana's log" 263 fi 264 265 for _ in $(seq 1 5); do 266 status=$(curl -sS -H 'Authorization: Bearer secret-token:stub' \ 267 --unix-socket "$MERCHANT_SOCKET" \ 268 -o /dev/null -w '%{http_code}' \ 269 "${INTERNAL_MERCHANT_URL}private/orders/diagnostic-order") 270 [ "$status" = "404" ] || fail "fresh merchant curl returned HTTP $status" 271 done 272 [ "$(grep -c '/private/orders/diagnostic-order' "$SCRATCH/merchant.log")" = 6 ] || \ 273 fail "fresh order-status curls did not all reach the merchant" 274 275 # A normal merchant response resets both warning sampling and the consecutive 276 # operational failure count. The diagnostic itself deliberately does not. 277 read -r status elapsed < <(redeem diagnostic-recovery-order) 278 [ "$status" = "404" ] || fail "diagnostic recovery returned HTTP $status" 279 expect_error 9802 diagnostic-recovery-order 404 || \ 280 fail "unexpected diagnostic-recovery JSON" 281 grep -q 'answered order .*diagnostic-recovery-order.* after 1 consecutive lookup' \ 282 "$SCRATCH/paivana.log" || fail "diagnostic recovery log missing" 283 284 # A refused connection completes immediately. It must no longer be 285 # presented as a five-second timeout. 286 kill -TERM "$STUB_PID" 287 wait "$STUB_PID" || true 288 STUB_PID="" 289 read -r status elapsed < <(redeem transport-failure) 290 [ "$status" = "502" ] || fail "early transport failure returned HTTP $status" 291 expect_error 9801 transport-failure 0 || fail "unexpected early-failure JSON" 292 python3 - "$elapsed" <<'PY' || fail "early failure took $elapsed seconds" 293 import sys 294 raise SystemExit(0 if float(sys.argv[1]) < 2.0 else 1) 295 PY 296 grep -q "early transport failure" "$SCRATCH/paivana.log" \ 297 || fail "early-failure diagnostic missing from log" 298 grep -q "1 concurrent merchant lookup including this one" "$SCRATCH/paivana.log" \ 299 || fail "active lookup count missing from log" 300 for _ in $(seq 1 50); do 301 grep -q 'Forced-fresh merchant diagnostic for order `transport-failure' \ 302 "$SCRATCH/paivana.log" && break 303 sleep 0.1 304 done 305 grep -q 'Forced-fresh merchant diagnostic for order `transport-failure.*completed HTTP status 0' \ 306 "$SCRATCH/paivana.log" || fail "failed fresh-connection diagnostic missing" 307 308 # A real HTTP response proves recovery and resets the consecutive 309 # transport-failure counter. 310 start_stub 311 read -r status elapsed < <(redeem recovered-order) 312 [ "$status" = "404" ] || fail "recovered backend returned HTTP $status" 313 expect_error 9802 recovered-order 404 || fail "unexpected recovery JSON" 314 grep -q 'answered order .*recovered-order.* after 1 consecutive lookup' \ 315 "$SCRATCH/paivana.log" || fail "recovery diagnostic missing from log" 316 317 # A backend that holds the long poll beyond Paivana's own deadline is a 318 # genuine timeout and must retain the 504/code-11 response. 319 read -r status elapsed < <(redeem timeout-order) 320 [ "$status" = "504" ] || fail "elapsed deadline returned HTTP $status" 321 expect_error 11 timeout-order 0 || fail "unexpected timeout JSON" 322 python3 - "$elapsed" <<'PY' || fail "timeout returned after only $elapsed seconds" 323 import sys 324 raise SystemExit(0 if float(sys.argv[1]) >= 4.5 else 1) 325 PY 326 grep -q "by the 5 s deadline" "$SCRATCH/paivana.log" \ 327 || fail "deadline diagnostic missing from log" 328 329 # Two long polls fill the payment budget. A third redemption must receive an 330 # immediate, machine-readable overload response while the first two remain 331 # suspended, and repeated timeout diagnostics in the same minute are sampled. 332 timeout_pids=() 333 for n in 1 2; do 334 curl -sS -o "$SCRATCH/timeout-$n.json" -w '%{http_code}' \ 335 -H 'Content-Type: application/json' -X POST \ 336 "http://127.0.0.1:$PAIVANA_PORT/.well-known/paivana" \ 337 -d "$(redemption_body timeout-order)" \ 338 >"$SCRATCH/timeout-$n.status" & 339 timeout_pids+=("$!") 340 done 341 sleep 0.3 342 status="$(curl -sS --max-time 2 -D "$SCRATCH/payment-overload.headers" \ 343 -o "$SCRATCH/response.json" -w '%{http_code}' \ 344 -H 'Content-Type: application/json' -X POST \ 345 "http://127.0.0.1:$PAIVANA_PORT/.well-known/paivana" \ 346 -d "$(redemption_body third-order)")" || \ 347 fail "over-capacity payment request failed" 348 [ "$status" = "503" ] || fail "third payment returned HTTP $status, want 503" 349 python3 - "$SCRATCH/response.json" <<'PY' || fail "payment overload JSON is wrong" 350 import json 351 import sys 352 with open(sys.argv[1], encoding="utf-8") as f: 353 body = json.load(f) 354 raise SystemExit(0 if body.get("code") == 77 else 1) 355 PY 356 grep -qi '^Retry-After: 1' "$SCRATCH/payment-overload.headers" || \ 357 fail "payment overload response lacks Retry-After: 1" 358 grep -qi '^Connection: close' "$SCRATCH/payment-overload.headers" || \ 359 fail "payment overload response lacks Connection: close" 360 wait "${timeout_pids[0]}" || fail "first concurrent timeout request failed" 361 wait "${timeout_pids[1]}" || fail "second concurrent timeout request failed" 362 [ "$(cat "$SCRATCH/timeout-1.status")" = 504 ] || \ 363 fail "first admitted payment did not complete with 504" 364 [ "$(cat "$SCRATCH/timeout-2.status")" = 504 ] || \ 365 fail "second admitted payment did not complete with 504" 366 [ "$(grep -c 'by the 5 s deadline' "$SCRATCH/paivana.log")" = 1 ] || \ 367 fail "timeout diagnostic was not sampled to one warning per minute" 368 369 echo "payment backend failure diagnostics: OK"