paivana

HTTP paywall reverse proxy
Log | Files | Refs | Submodules | README | LICENSE

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"