paivana

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

test_paywall.sh (32258B)


      1 #!/usr/bin/env bash
      2 # This file is part of GNU Taler
      3 # Copyright (C) 2025, 2026 Taler Systems SA
      4 #
      5 # GNU Taler is free software; you can redistribute it and/or modify it under
      6 # the terms of the GNU Affero General Public License as published by the Free
      7 # Software Foundation; either version 3, or (at your option) any later
      8 # version.
      9 #
     10 # GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
     11 # WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
     12 # FOR A PARTICULAR PURPOSE.  See the GNU Affero General Public License for
     13 # more details.
     14 #
     15 # You should have received a copy of the GNU Affero General Public License
     16 # along with GNU Taler; see the file COPYING.  If not, see
     17 # <http://www.gnu.org/licenses/>
     18 #
     19 #
     20 # The paywall, against a real merchant backend.
     21 #
     22 # test_reverse_proxy.sh runs paivana-httpd with `-n', which switches the
     23 # paywall off before the first byte of a request is looked at, so it
     24 # exercises the proxy and nothing else.  Everything paivana exists for --
     25 # the paywall page, the payment, the access cookie -- is on the other side
     26 # of that flag and had no automated coverage at all.
     27 #
     28 # This test puts a real GNU Taler system behind paivana-httpd with
     29 # taler-unified-setup.sh (a fakebank, an exchange and a merchant backend,
     30 # the same way merchant and anastasis test their own payment paths), buys
     31 # access with taler-wallet-cli, and checks what the daemon does with it.
     32 #
     33 # The client half is written out by hand rather than driven through a
     34 # browser: the paywall page's JavaScript computes a payment identifier from
     35 # (nonce, website, expiration) and the daemon computes the same identifier
     36 # independently, so the two agreeing IS the protocol -- it is never sent
     37 # from one to the other.  paivana_id.py re-derives it from the definition
     38 # src/frontend/paywall.js implements, which is what makes this a test of
     39 # both ends rather than of one end twice.
     40 #
     41 # Set PAIVANA_REPRO_MERCHANT_POOL_FAILURE=1 to pay a genuine order and then
     42 # reproduce the production status-zero failure through merchant_fault_proxy.py
     43 # instead of completing the successful redemption checks.
     44 
     45 set -eu
     46 
     47 # shellcheck source=src/tests/setup.sh
     48 . "$(cd -- "$(dirname -- "$0")" && pwd)/setup.sh"
     49 
     50 # Ports of the Taler system come from test_paywall.conf and are fixed;
     51 # paivana's own move as a block, exactly as in test_reverse_proxy.sh.
     52 PORT_BASE="${PAIVANA_PORT_BASE:-18400}"
     53 PAIVANA_PORT=$((PORT_BASE + 110))
     54 ORIGIN_PORT=$((PORT_BASE + 111))
     55 MERCHANT_PROXY_PORT=$((PORT_BASE + 112))
     56 MERCHANT_PORT=9966
     57 EXCHANGE_PORT=8081
     58 BANK_PORT=8082
     59 FAULT_REPRO="${PAIVANA_REPRO_MERCHANT_POOL_FAILURE:-0}"
     60 
     61 function here() {
     62     cd -- "$(dirname -- "$0")" && pwd
     63 }
     64 
     65 # Same convention as test_reverse_proxy.sh: meson passes both, and the
     66 # defaults let the script be run by hand from either tree.
     67 SRCDIR="${SRCDIR:-$(here)}"
     68 BUILDDIR="${BUILDDIR:-$PWD}"
     69 
     70 PAIVANA_HTTPD="${PAIVANA_HTTPD:-$BUILDDIR/../backend/paivana-httpd}"
     71 UPSTREAM_MHD="${UPSTREAM_MHD:-$BUILDDIR/upstream_mhd}"
     72 
     73 CHECKS=0
     74 FAILS=0
     75 
     76 function ok() {
     77     CHECKS=$((CHECKS + 1))
     78     echo "  OK: $1"
     79 }
     80 
     81 function fail() {
     82     CHECKS=$((CHECKS + 1))
     83     FAILS=$((FAILS + 1))
     84     printf '  FAIL: %b\n' "$1" >&2
     85 }
     86 
     87 # Is a TCP port free?  Same probe as test_reverse_proxy.sh: bash's
     88 # /dev/tcp in a subshell, so a failed connect cannot kill the script.
     89 function port_is_free() {
     90     ! (exec 3<>"/dev/tcp/127.0.0.1/$1") 2>/dev/null
     91 }
     92 
     93 function wait_for_port() {
     94     local port="$1" pid="${2:-}" tries=100
     95     while [ "$tries" -gt 0 ]
     96     do
     97         if ! port_is_free "$port"
     98         then
     99             return 0
    100         fi
    101         # Probe first: a child that handed the listening socket on and
    102         # exited is still a service that came up.
    103         if [ -n "$pid" ] && ! kill -0 "$pid" 2>/dev/null
    104         then
    105             return 1
    106         fi
    107         sleep 0.1
    108         tries=$((tries - 1))
    109     done
    110     return 1
    111 }
    112 
    113 # --------------------------------------------------------------------
    114 # Preconditions.  Every one of these is an environment problem rather
    115 # than a regression, so each exits 77 naming what is missing.
    116 # --------------------------------------------------------------------
    117 
    118 echo -n "Checking for curl ..."
    119 curl --version >/dev/null 2>&1 || exit_skip "no curl"
    120 echo " OK"
    121 echo -n "Checking for jq ..."
    122 jq --version >/dev/null 2>&1 || exit_skip "no jq"
    123 echo " OK"
    124 echo -n "Checking for python3 ..."
    125 python3 --version >/dev/null 2>&1 || exit_skip "no python3"
    126 echo " OK"
    127 echo -n "Checking for taler-unified-setup.sh ..."
    128 command -v taler-unified-setup.sh >/dev/null 2>&1 \
    129     || exit_skip "no taler-unified-setup.sh (the Taler stack is not installed)"
    130 echo " OK"
    131 echo -n "Checking for taler-wallet-cli ..."
    132 command -v taler-wallet-cli >/dev/null 2>&1 || exit_skip "no taler-wallet-cli"
    133 echo " OK"
    134 echo -n "Checking for taler-merchant-httpd ..."
    135 command -v taler-merchant-httpd >/dev/null 2>&1 || exit_skip "no taler-merchant-httpd"
    136 echo " OK"
    137 echo -n "Checking for PostgreSQL ..."
    138 pg_isready >/dev/null 2>&1 || exit_skip "PostgreSQL is not accepting connections"
    139 echo " OK"
    140 echo -n "Checking for paivana-httpd ..."
    141 [ -x "$PAIVANA_HTTPD" ] || exit_skip "no paivana-httpd at $PAIVANA_HTTPD"
    142 echo " OK"
    143 echo -n "Checking for the test upstream ..."
    144 [ -x "$UPSTREAM_MHD" ] || exit_skip "no upstream_mhd at $UPSTREAM_MHD"
    145 echo " OK"
    146 
    147 # The paywall page is a template paivana loads from its installation
    148 # prefix, so a build tree alone is not enough.  Rather than demanding
    149 # `make install', stage the one file into a prefix of our own and point
    150 # PAIVANA_PREFIX at it -- the same variable paivana's project data
    151 # declares for exactly this.
    152 PAYWALL_TEMPLATE=""
    153 for cand in "$BUILDDIR/../frontend/paywall.en.must" \
    154             "$SRCDIR/../frontend/paywall.en.must"
    155 do
    156     if [ -r "$cand" ]
    157     then
    158         PAYWALL_TEMPLATE="$cand"
    159         break
    160     fi
    161 done
    162 if [ -z "$PAYWALL_TEMPLATE" ]
    163 then
    164     exit_skip "the paywall template has not been built"
    165 fi
    166 
    167 echo -n "Checking that the ports are free ..."
    168 BUSY=""
    169 for p in "$PAIVANA_PORT" "$ORIGIN_PORT" "$MERCHANT_PORT" "$EXCHANGE_PORT" "$BANK_PORT"
    170 do
    171     port_is_free "$p" || BUSY="$BUSY $p"
    172 done
    173 if [ "$FAULT_REPRO" = 1 ] && ! port_is_free "$MERCHANT_PROXY_PORT"
    174 then
    175     BUSY="$BUSY $MERCHANT_PROXY_PORT"
    176 fi
    177 if [ -n "$BUSY" ]
    178 then
    179     # The Taler ports are the ones the merchant suite uses, so this is
    180     # most often another Taler test running, not a leftover.  paivana's
    181     # own two move with PAIVANA_PORT_BASE.
    182     exit_skip "port(s) already in use:$BUSY"
    183 fi
    184 echo " OK"
    185 
    186 # --------------------------------------------------------------------
    187 # Scratch space.  NOT named TMPDIR: that is the variable every child
    188 # reads for its own temporary files, and cleanup removes this directory
    189 # while the Taler services may still be running out of it.
    190 # --------------------------------------------------------------------
    191 
    192 SCRATCH="$(mktemp -d -t paivana-paywall.XXXXXX)"
    193 PIDS=""
    194 
    195 function stop_children() {
    196     local pid
    197     for pid in $PIDS
    198     do
    199         kill -TERM "$pid" 2>/dev/null || true
    200     done
    201     for pid in $PIDS
    202     do
    203         wait "$pid" 2>/dev/null || true
    204     done
    205     PIDS=""
    206 }
    207 
    208 function paywall_cleanup() {
    209     stop_children
    210     # exit_cleanup (from setup.sh) stops taler-unified-setup.sh.
    211     exit_cleanup
    212     if [ -z "${KEEP_TMP:-}" ]
    213     then
    214         rm -rf "$SCRATCH"
    215     else
    216         echo "Keeping $SCRATCH" >&2
    217     fi
    218 }
    219 trap paywall_cleanup EXIT
    220 
    221 mkdir -p "$SCRATCH/prefix/share/paivana/templates"
    222 cp "$PAYWALL_TEMPLATE" "$SCRATCH/prefix/share/paivana/templates/"
    223 export PAIVANA_PREFIX="$SCRATCH/prefix/"
    224 
    225 # --------------------------------------------------------------------
    226 # The Taler system.
    227 # --------------------------------------------------------------------
    228 
    229 CONF="$SRCDIR/test_paywall.conf"
    230 [ -r "$CONF" ] || exit_fail "cannot read $CONF"
    231 
    232 # taler-unified-setup.sh resolves TALER_TEST_HOME relative to the working
    233 # directory and writes into it, so run it from the scratch directory with
    234 # an absolute path to the configuration.
    235 CONF="$(cd "$(dirname "$CONF")" && pwd)/$(basename "$CONF")"
    236 cd "$SCRATCH"
    237 
    238 # Give the exchange and the merchant a database of their own.  Reusing
    239 # `talercheck' would have this test and the merchant's own tests
    240 # clobbering each other's tables.
    241 DB=paivanacheck
    242 if ! psql -Aqt -c "SELECT 1" "$DB" >/dev/null 2>&1
    243 then
    244     createdb "$DB" >/dev/null 2>&1 \
    245         || exit_skip "cannot create the PostgreSQL database $DB"
    246 fi
    247 
    248 setup -c "$CONF" \
    249       -r "merchant-exchange-default" \
    250       -em \
    251       -f -d x-taler-bank -u exchange-account-2
    252 
    253 MERCHANT_URL="http://localhost:$MERCHANT_PORT/"
    254 PAIVANA_MERCHANT_URL="$MERCHANT_URL"
    255 AUTH="Authorization: Bearer secret-token:super_secret"
    256 RESP="$SCRATCH/response.json"
    257 
    258 function merchant_post() {
    259     local path="$1" body="$2"
    260     curl -s -o "$RESP" -w "%{http_code}" \
    261          -H "Content-Type: application/json" \
    262          -H "$AUTH" \
    263          -X POST \
    264          "${MERCHANT_URL}${path}" \
    265          -d "$body"
    266 }
    267 
    268 echo -n "Configuring merchant instance ..."
    269 STATUS=$(merchant_post management/instances \
    270     '{"auth":{"method":"token","token":"secret-token:super_secret"},
    271       "id":"admin","name":"default","user_type":"business",
    272       "address":{},"jurisdiction":{},"use_stefan":true,
    273       "default_wire_transfer_delay":{"d_us":50000000000},
    274       "default_pay_delay":{"d_us":60000000000}}')
    275 [ "$STATUS" = "204" ] || exit_fail "creating the instance: got $STATUS, $(cat "$RESP")"
    276 echo " OK"
    277 
    278 if [ "$FAULT_REPRO" = 1 ]
    279 then
    280     echo -n "Starting the merchant fault frontend ..."
    281     python3 "$SRCDIR/merchant_fault_proxy.py" \
    282         --listen-port "$MERCHANT_PROXY_PORT" \
    283         --upstream-port "$MERCHANT_PORT" \
    284         --control-file "$SCRATCH/merchant-fault.control" \
    285         > "$SCRATCH/merchant-proxy.log" 2>&1 &
    286     MERCHANT_PROXY_PID=$!
    287     PIDS="$PIDS $MERCHANT_PROXY_PID"
    288     wait_for_port "$MERCHANT_PROXY_PORT" "$MERCHANT_PROXY_PID" \
    289         || exit_fail "the merchant fault frontend did not start"
    290     PAIVANA_MERCHANT_URL="http://127.0.0.1:$MERCHANT_PROXY_PORT/"
    291     echo " OK"
    292 fi
    293 
    294 echo -n "Configuring merchant bank account ..."
    295 STATUS=$(merchant_post private/accounts \
    296     '{"payto_uri":"payto://x-taler-bank/localhost/fortythree?receiver-name=fortythree"}')
    297 [ "$STATUS" = "200" ] || exit_fail "adding the account: got $STATUS, $(cat "$RESP")"
    298 echo " OK"
    299 
    300 # website_regex is `.*' so that every URL is paywalled and WHITELIST is
    301 # the only exemption; that is what lets the whitelist cases below mean
    302 # what they say.  With a narrower expression a URL matching no template
    303 # is served for free, which would make a whitelist miss indistinguishable
    304 # from a template miss.
    305 echo -n "Creating the Paivana template ..."
    306 STATUS=$(merchant_post private/templates \
    307     '{"template_id":"premium",
    308       "template_description":"Paywalled content",
    309       "template_contract":{"template_type":"paivana",
    310                            "summary":"Access to the article",
    311                            "website_regex":".*",
    312                            "max_pickup_duration":{"d_us":3600000000},
    313                            "choices":[{"amount":"TESTKUDOS:1"}]}}')
    314 [ "$STATUS" = "204" ] || exit_fail "creating the template: got $STATUS, $(cat "$RESP")"
    315 echo " OK"
    316 
    317 # --------------------------------------------------------------------
    318 # The origin and paivana itself.
    319 # --------------------------------------------------------------------
    320 
    321 BASE_URL="http://localhost:$PAIVANA_PORT/"
    322 
    323 cat > "$SCRATCH/paivana.conf" <<EOF
    324 [paivana]
    325 DESTINATION_BASE_URL = http://localhost:$ORIGIN_PORT/
    326 BASE_URL = $BASE_URL
    327 MERCHANT_BACKEND_URL = $PAIVANA_MERCHANT_URL
    328 MERCHANT_ACCESS_TOKEN = secret-token:super_secret
    329 # A fixed secret so that a cookie minted here can be reasoned about; a
    330 # real deployment must not do this (see the manual on SECRET).
    331 SECRET = paivana-integration-test-secret
    332 WHITELIST = /echo-headers|/large/.*
    333 SERVE = tcp
    334 PORT = $PAIVANA_PORT
    335 EOF
    336 
    337 echo -n "Starting the origin ..."
    338 "$UPSTREAM_MHD" "$ORIGIN_PORT" > "$SCRATCH/origin.log" 2>&1 &
    339 ORIGIN_PID=$!
    340 PIDS="$PIDS $ORIGIN_PID"
    341 wait_for_port "$ORIGIN_PORT" "$ORIGIN_PID" \
    342     || exit_fail "the test origin did not start on port $ORIGIN_PORT"
    343 echo " OK"
    344 
    345 echo -n "Starting paivana-httpd ..."
    346 "$PAIVANA_HTTPD" -c "$SCRATCH/paivana.conf" -L INFO > "$SCRATCH/paivana.log" 2>&1 &
    347 PAIVANA_PID=$!
    348 PIDS="$PIDS $PAIVANA_PID"
    349 # paivana does not listen until it has fetched the templates, so this
    350 # also asserts that the merchant handed them over.
    351 wait_for_port "$PAIVANA_PORT" "$PAIVANA_PID" \
    352     || exit_fail "paivana-httpd did not start:\n$(cat "$SCRATCH/paivana.log")"
    353 echo " OK"
    354 
    355 # --------------------------------------------------------------------
    356 # What the paywall does before anyone has paid.
    357 # --------------------------------------------------------------------
    358 
    359 echo "-- before payment --"
    360 
    361 STATUS=$(curl -s -o /dev/null -w "%{http_code}" "${BASE_URL}hello")
    362 if [ "$STATUS" = "302" ]
    363 then
    364     ok "an unpaid URL is redirected to the paywall"
    365 else
    366     fail "an unpaid URL should be 302, got $STATUS"
    367 fi
    368 
    369 curl -s -D "$SCRATCH/redirect.hdr" -o /dev/null "${BASE_URL}hello"
    370 LOCATION=$(grep -i '^location:' "$SCRATCH/redirect.hdr" | tr -d '\r' | cut -d' ' -f2-)
    371 case "$LOCATION" in
    372     *"/.well-known/paivana/templates/premium#"*)
    373         ok "the redirect names the template and carries the website in the fragment"
    374         ;;
    375     *)
    376         fail "unexpected paywall redirect: $LOCATION"
    377         ;;
    378 esac
    379 
    380 # The fragment is the website, base64url-encoded; the page's JavaScript
    381 # atob()s it, so a '-' or '_' in it has to survive.
    382 FRAGMENT="${LOCATION#*#}"
    383 DECODED=$(python3 -c '
    384 import base64, sys
    385 s = sys.argv[1]
    386 print(base64.urlsafe_b64decode(s + "=" * (-len(s) % 4)).decode("utf-8"))' "$FRAGMENT")
    387 if [ "$DECODED" = "${BASE_URL}hello" ]
    388 then
    389     ok "the fragment decodes to the URL that was asked for"
    390 else
    391     fail "fragment decodes to '$DECODED', want '${BASE_URL}hello'"
    392 fi
    393 
    394 PAGE_STATUS=$(curl -s -D "$SCRATCH/page.hdr" -o "$SCRATCH/page.html" \
    395                    -w "%{http_code}" \
    396                    "${BASE_URL}.well-known/paivana/templates/premium")
    397 if [ "$PAGE_STATUS" = "402" ]
    398 then
    399     ok "the paywall page is served with 402 Payment Required"
    400 else
    401     fail "the paywall page should be 402, got $PAGE_STATUS"
    402 fi
    403 
    404 PAIVANA_HEADER=$(grep -i '^paivana:' "$SCRATCH/page.hdr" | tr -d '\r' | cut -d' ' -f2-)
    405 if [ "$FAULT_REPRO" = 1 ]
    406 then
    407     EXPECTED_PAY_TEMPLATE="taler+http://pay-template/127.0.0.1:$MERCHANT_PROXY_PORT/premium"
    408 else
    409     EXPECTED_PAY_TEMPLATE="taler+http://pay-template/localhost:$MERCHANT_PORT/premium"
    410 fi
    411 case "$PAIVANA_HEADER" in
    412     "$EXPECTED_PAY_TEMPLATE")
    413         ok "the Paivana header carries the pay-template URI"
    414         ;;
    415     *)
    416         fail "unexpected Paivana header: '$PAIVANA_HEADER'"
    417         ;;
    418 esac
    419 
    420 if grep -qi "^content-security-policy:.*default-src 'none'" "$SCRATCH/page.hdr"
    421 then
    422     ok "the paywall page carries a Content-Security-Policy"
    423 else
    424     fail "the paywall page has no restrictive CSP"
    425 fi
    426 
    427 # --------------------------------------------------------------------
    428 # The whitelist, which only exists on this side of the paywall.
    429 #
    430 # README and paivana.conf(5) both promise that a WHITELIST expression is
    431 # matched against the whole path and anchored at both ends, so that
    432 # `/free/' waives nothing while `/free/.*' waives a subtree.  The
    433 # regexec that decides it sits behind the paywall `-n' switches off, so
    434 # no other test in the tree can reach these.
    435 # --------------------------------------------------------------------
    436 
    437 echo "-- the whitelist --"
    438 
    439 function expect_forwarded() {
    440     local path="$1" what="$2" status
    441     status=$(curl -s -D "$SCRATCH/w.hdr" -o /dev/null -w "%{http_code}" "${BASE_URL}${path}")
    442     if [ "302" = "$status" ]
    443     then
    444         fail "$what: /$path was paywalled (302)"
    445     elif grep -qi '^x-upstream:' "$SCRATCH/w.hdr"
    446     then
    447         ok "$what"
    448     else
    449         fail "$what: /$path answered $status but not by the origin"
    450     fi
    451 }
    452 
    453 function expect_paywalled() {
    454     local path="$1" what="$2" status
    455     status=$(curl -s -o /dev/null -w "%{http_code}" "${BASE_URL}${path}")
    456     if [ "302" = "$status" ]
    457     then
    458         ok "$what"
    459     else
    460         fail "$what: /$path answered $status, want 302"
    461     fi
    462 }
    463 
    464 expect_forwarded "echo-headers" "a whitelisted path is served without payment"
    465 expect_forwarded "large/64" "an alternation branch is whitelisted too"
    466 expect_paywalled "x/echo-headers" \
    467     "a path merely containing the whitelisted one is not waived (left anchor)"
    468 expect_paywalled "echo-headers/x" \
    469     "a path extending the whitelisted one is not waived (right anchor)"
    470 expect_paywalled "hello" "a path outside the whitelist is paywalled"
    471 
    472 # --------------------------------------------------------------------
    473 # The payment endpoint's refusals, before we have anything valid to send.
    474 # --------------------------------------------------------------------
    475 
    476 echo "-- the payment endpoint --"
    477 
    478 STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
    479               -H "Content-Type: application/json" \
    480               "${BASE_URL}.well-known/paivana" -d '{}')
    481 if [ "$STATUS" = "400" ]
    482 then
    483     ok "a redemption without the required fields is refused with 400"
    484 else
    485     fail "an empty redemption should be 400, got $STATUS"
    486 fi
    487 
    488 # A nonce is 16 bytes in GNUnet's Crockford base32, which is 26
    489 # characters; anything else must not reach the order lookup.  25 zeroes
    490 # is the off-by-one an implementer would write.
    491 STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
    492               -H "Content-Type: application/json" \
    493               "${BASE_URL}.well-known/paivana" \
    494               -d "$(jq -n --arg w "${BASE_URL}hello" \
    495                     '{order_id:"no-such-order",nonce:"0000000000000000000000000",
    496                       expiration:{t_s:2000000000},website:$w}')")
    497 if [ "$STATUS" = "400" ]
    498 then
    499     ok "a redemption with a nonce of the wrong length is refused with 400"
    500 else
    501     fail "a short nonce should be 400, got $STATUS"
    502 fi
    503 
    504 # Well-formed, but naming an order the merchant has never heard of: this
    505 # has to reach the backend and come back as a refusal rather than as a
    506 # parse error, which is what tells the two apart in a log.
    507 read -r JUNK_NONCE _ < <(python3 "$SRCDIR/paivana_id.py" 2000000000 "${BASE_URL}hello" \
    508                                  000102030405060708090a0b0c0d0e0f)
    509 STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
    510               -H "Content-Type: application/json" \
    511               "${BASE_URL}.well-known/paivana" \
    512               -d "$(jq -n --arg w "${BASE_URL}hello" --arg n "$JUNK_NONCE" \
    513                     '{order_id:"no-such-order",nonce:$n,
    514                       expiration:{t_s:2000000000},website:$w}')")
    515 if [ "$STATUS" = "404" ] || [ "$STATUS" = "409" ]
    516 then
    517     ok "a redemption naming an order that was never paid is refused ($STATUS)"
    518 else
    519     fail "an unknown order should be 404 or 409, got $STATUS"
    520 fi
    521 
    522 # --------------------------------------------------------------------
    523 # Buy access.
    524 # --------------------------------------------------------------------
    525 
    526 echo "-- paying --"
    527 
    528 WALLET="$SCRATCH/wallet.sqlite3"
    529 
    530 echo -n "Withdrawing test coins ..."
    531 taler-wallet-cli --no-throttle --wallet-db="$WALLET" \
    532     api --expect-success 'withdrawTestBalance' \
    533     "$(jq -n --arg bank "http://localhost:$BANK_PORT/" \
    534              --arg exchange "http://localhost:$EXCHANGE_PORT/" \
    535         '{amount:"TESTKUDOS:20",corebankApiBaseUrl:$bank,exchangeBaseUrl:$exchange}')" \
    536     > "$SCRATCH/withdraw.out" 2> "$SCRATCH/withdraw.err" \
    537     || exit_fail "withdrawTestBalance failed:\n$(cat "$SCRATCH/withdraw.err")"
    538 taler-exchange-wirewatch -a exchange-account-2 -c "$CONF" -t \
    539     > "$SCRATCH/wirewatch.log" 2>&1 \
    540     || exit_fail "wirewatch failed:\n$(cat "$SCRATCH/wirewatch.log")"
    541 timeout 240 taler-wallet-cli --no-throttle --wallet-db="$WALLET" run-until-done \
    542     > "$SCRATCH/withdraw2.out" 2> "$SCRATCH/withdraw2.err" \
    543     || exit_fail "the withdrawal did not finish:\n$(cat "$SCRATCH/withdraw2.err")"
    544 echo " OK"
    545 
    546 WEBSITE="${BASE_URL}hello"
    547 # The page computes this as `now + max_pickup_duration'; one hour is what
    548 # the template above allows.
    549 EXPIRATION=$(( $(date +%s) + 3600 ))
    550 read -r NONCE PAIVANA_ID < <(python3 "$SRCDIR/paivana_id.py" "$EXPIRATION" "$WEBSITE")
    551 echo "  paivana_id: $PAIVANA_ID"
    552 
    553 function urlenc() {
    554     python3 -c 'import sys,urllib.parse;print(urllib.parse.quote(sys.argv[1],safe=""))' "$1"
    555 }
    556 
    557 PAY_URI="taler+http://pay-template/localhost:$MERCHANT_PORT/premium"
    558 PAY_URI="$PAY_URI?session_id=$(urlenc "$PAIVANA_ID")"
    559 PAY_URI="$PAY_URI&fulfillment_url=$(urlenc "$WEBSITE")"
    560 
    561 echo -n "Paying ..."
    562 # "0" selects the first (and only) choice.
    563 echo "0" | timeout 240 taler-wallet-cli --no-throttle --wallet-db="$WALLET" \
    564     handle-uri "$PAY_URI" -y > "$SCRATCH/pay.out" 2> "$SCRATCH/pay.err" \
    565     || exit_fail "paying failed:\n$(cat "$SCRATCH/pay.err")"
    566 echo " OK"
    567 
    568 # This is what the paywall page polls: the merchant reports the order it
    569 # has seen paid under our session, which is the paivana ID.
    570 SESSION_URL="${MERCHANT_URL}sessions/$(urlenc "$PAIVANA_ID")"
    571 SESSION_URL="$SESSION_URL?fulfillment_url=$(urlenc "$WEBSITE")&timeout_ms=10000"
    572 STATUS=$(curl -s -o "$RESP" -w "%{http_code}" "$SESSION_URL")
    573 ORDER_ID=$(jq -r '.order_id // empty' < "$RESP")
    574 if [ "$STATUS" = "200" ] && [ -n "$ORDER_ID" ]
    575 then
    576     ok "the merchant reports the order paid under the paivana ID we derived"
    577 else
    578     fail "the merchant did not report a paid order: $STATUS, $(cat "$RESP")"
    579     echo "=== $CHECKS checks, $FAILS failures ==="
    580     exit 1
    581 fi
    582 
    583 # --------------------------------------------------------------------
    584 # Redeem it.
    585 # --------------------------------------------------------------------
    586 
    587 echo "-- redeeming --"
    588 
    589 function redeem() {
    590     local website="$1" expiration="$2" nonce="$3" order="$4" hdr="$5"
    591     curl -s -D "$hdr" -o "$SCRATCH/redeem.body" -w "%{http_code}" -X POST \
    592          -H "Content-Type: application/json" \
    593          "${BASE_URL}.well-known/paivana" \
    594          -d "$(jq -n --arg o "$order" --arg n "$nonce" \
    595                    --argjson e "$expiration" --arg w "$website" \
    596                '{order_id:$o,nonce:$n,expiration:{t_s:$e},website:$w}')"
    597 }
    598 
    599 if [ "$FAULT_REPRO" = 1 ]
    600 then
    601     # Atomic replacement keeps the proxy from observing a truncated control
    602     # file.  Three resets cover libcurl's retry of an idle reused connection
    603     # and Paivana's one application-level retry; the forced-fresh diagnostic
    604     # that follows is allowed through to the real merchant.
    605     printf '/private/orders/%s 3\n' "$ORDER_ID" \
    606         > "$SCRATCH/merchant-fault.control.new"
    607     mv "$SCRATCH/merchant-fault.control.new" \
    608        "$SCRATCH/merchant-fault.control"
    609 fi
    610 
    611 STATUS=$(redeem "$WEBSITE" "$EXPIRATION" "$NONCE" "$ORDER_ID" "$SCRATCH/redeem.hdr")
    612 if [ "$FAULT_REPRO" = 1 ]
    613 then
    614     if [ "$STATUS" = 502 ] && \
    615        [ "$(jq -r '.code' < "$SCRATCH/redeem.body")" = 9801 ] && \
    616        [ "$(jq -r '.merchant_http_status' < "$SCRATCH/redeem.body")" = 0 ]
    617     then
    618         ok "Paivana reproduces 502/error 9801 with merchant_http_status zero"
    619     else
    620         fail "faulted redemption returned $STATUS: $(cat "$SCRATCH/redeem.body")"
    621     fi
    622 
    623     for _ in $(seq 1 50)
    624     do
    625         grep -q "Forced-fresh merchant diagnostic for order \`$ORDER_ID'.*completed HTTP status 200" \
    626             "$SCRATCH/paivana.log" && break
    627         sleep 0.1
    628     done
    629     if grep -q "Forced-fresh merchant diagnostic for order \`$ORDER_ID'.*completed HTTP status 200" \
    630         "$SCRATCH/paivana.log"
    631     then
    632         ok "a forced-fresh request reaches the real merchant immediately afterwards"
    633     else
    634         fail "the forced-fresh merchant diagnostic did not recover"
    635     fi
    636 
    637     if grep -q "FAULT index=1 .*path=/private/orders/$ORDER_ID reused=yes" \
    638         "$SCRATCH/merchant-proxy.log" && \
    639        [ "$(grep -c "FAULT .*path=/private/orders/$ORDER_ID" \
    640                     "$SCRATCH/merchant-proxy.log")" = 3 ]
    641     then
    642         ok "the first failed transport used an idle pooled connection and all retries were covered"
    643     else
    644         fail "fault frontend did not exercise the expected pooled/retry path"
    645     fi
    646 
    647     if [ "$(grep -c "ACCESS .*path=/private/orders/$ORDER_ID" \
    648                   "$SCRATCH/merchant-proxy.log")" = 1 ]
    649     then
    650         ok "none of Paivana's failed attempts reached the frontend access log"
    651     else
    652         fail "a failed order lookup appeared in the frontend access log"
    653     fi
    654 
    655     FRESH_URL="${PAIVANA_MERCHANT_URL}private/orders/${ORDER_ID}"
    656     FRESH_URL="$FRESH_URL?session_id=$(urlenc "$PAIVANA_ID")"
    657     FRESH_OK=1
    658     for _ in $(seq 1 5)
    659     do
    660         STATUS=$(curl -s -o "$RESP" -w "%{http_code}" -H "$AUTH" "$FRESH_URL")
    661         [ "$STATUS" = 200 ] || FRESH_OK=0
    662     done
    663     if [ "$FRESH_OK" = 1 ]
    664     then
    665         ok "five fresh order-status curls all succeed through the same frontend"
    666     else
    667         fail "a fresh order-status curl failed after the Paivana incident"
    668     fi
    669 
    670     echo "=== $CHECKS checks, $FAILS failures ==="
    671     [ "$FAILS" = 0 ]
    672     exit
    673 fi
    674 
    675 if [ "$STATUS" = "303" ]
    676 then
    677     ok "a paid order is redeemed with 303"
    678 else
    679     fail "redemption should be 303, got $STATUS"
    680 fi
    681 
    682 COOKIE=$(grep -i '^set-cookie:' "$SCRATCH/redeem.hdr" | tr -d '\r' \
    683              | sed -n 's/.*\(Paivana-Cookie=[^;]*\).*/\1/p')
    684 if [ -n "$COOKIE" ]
    685 then
    686     ok "the redemption sets an access cookie"
    687 else
    688     fail "no Paivana-Cookie in the redemption response"
    689 fi
    690 
    691 if grep -qi '^set-cookie:.*HttpOnly' "$SCRATCH/redeem.hdr"
    692 then
    693     ok "the access cookie is HttpOnly"
    694 else
    695     fail "the access cookie is not HttpOnly"
    696 fi
    697 
    698 REDIRECT=$(grep -i '^location:' "$SCRATCH/redeem.hdr" | tr -d '\r' | cut -d' ' -f2-)
    699 if [ "$REDIRECT" = "$WEBSITE" ]
    700 then
    701     ok "the redemption redirects back to the paid URL"
    702 else
    703     fail "redemption redirects to '$REDIRECT', want '$WEBSITE'"
    704 fi
    705 
    706 # Nothing the client sends is taken on trust.  Naming a different website
    707 # changes the paivana ID -- it is derived from the website -- so the
    708 # merchant is asked about a session it has never seen paid.
    709 STATUS=$(redeem "${BASE_URL}item" "$EXPIRATION" "$NONCE" "$ORDER_ID" "$SCRATCH/x.hdr")
    710 if [ "$STATUS" = "303" ]
    711 then
    712     fail "the order was redeemed for a URL it did not pay for"
    713 else
    714     ok "another website derives another paivana ID, so the order is not found ($STATUS)"
    715 fi
    716 
    717 # That check passes because of the session binding, and would pass even
    718 # if check_contract() never looked at the contract.  This one reaches the
    719 # fulfillment-URL comparison itself: buy an order whose session IS the one
    720 # we will claim, but whose fulfillment URL is somewhere else entirely.
    721 # The merchant will sell it -- website_regex is `.*' -- and the session
    722 # lookup will succeed, so the only thing standing between this and a
    723 # cookie for a page nobody paid for is paivana comparing the contract's
    724 # fulfillment URL against the website being claimed.
    725 echo "-- an order bought for somewhere else --"
    726 
    727 TARGET="${BASE_URL}item"
    728 ELSEWHERE="${BASE_URL}elsewhere"
    729 EXP2=$(( $(date +%s) + 3600 ))
    730 read -r NONCE2 PAIVANA_ID2 < <(python3 "$SRCDIR/paivana_id.py" "$EXP2" "$TARGET")
    731 
    732 STATUS=$(curl -s -o "$RESP" -w "%{http_code}" \
    733               -H "Content-Type: application/json" \
    734               -X POST "${MERCHANT_URL}templates/premium" \
    735               -d "$(jq -n --arg w "$ELSEWHERE" --arg s "$PAIVANA_ID2" \
    736                     '{template_type:"paivana",website:$w,paivana_id:$s}')")
    737 if [ "$STATUS" != "200" ]
    738 then
    739     fail "could not create the mismatched order: $STATUS, $(cat "$RESP")"
    740 else
    741     ORDER2=$(jq -r '.order_id' < "$RESP")
    742     STATUS=$(curl -s -o "$RESP" -w "%{http_code}" -H "$AUTH" \
    743                   "${MERCHANT_URL}private/orders/${ORDER2}?session_id=$(urlenc "$PAIVANA_ID2")")
    744     PAY2=$(jq -r '.taler_pay_uri // empty' < "$RESP")
    745     if [ -z "$PAY2" ]
    746     then
    747         fail "no pay URI for the mismatched order: $STATUS, $(cat "$RESP")"
    748     else
    749         echo "0" | timeout 240 taler-wallet-cli --no-throttle --wallet-db="$WALLET" \
    750             handle-uri "$PAY2" -y > "$SCRATCH/pay2.out" 2> "$SCRATCH/pay2.err" \
    751             || fail "paying the mismatched order failed:\n$(cat "$SCRATCH/pay2.err")"
    752 
    753         # Confirm the trap is armed: the merchant really does report this
    754         # order as paid under the session we are about to claim.
    755         STATUS=$(curl -s -o "$RESP" -w "%{http_code}" \
    756                       "${MERCHANT_URL}sessions/$(urlenc "$PAIVANA_ID2")?fulfillment_url=$(urlenc "$ELSEWHERE")&timeout_ms=10000")
    757         if [ "$STATUS" = "200" ] && [ -n "$(jq -r '.order_id // empty' < "$RESP")" ]
    758         then
    759             ok "the mismatched order is paid and bound to the session we will claim"
    760         else
    761             fail "the mismatched order was not paid: $STATUS, $(cat "$RESP")"
    762         fi
    763 
    764         STATUS=$(redeem "$TARGET" "$EXP2" "$NONCE2" "$ORDER2" "$SCRATCH/mm.hdr")
    765         if [ "$STATUS" = "303" ]
    766         then
    767             fail "an order bought for $ELSEWHERE minted a cookie for $TARGET"
    768         else
    769             ok "an order whose contract names another URL is refused ($STATUS)"
    770         fi
    771 
    772         STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$TARGET")
    773         if [ "$STATUS" = "302" ]
    774         then
    775             ok "and that URL is still paywalled afterwards"
    776         else
    777             fail "$TARGET answered $STATUS after the refused redemption"
    778         fi
    779     fi
    780 fi
    781 
    782 # The expiration is hashed into the paivana ID, so asking for a longer one
    783 # than was paid for cannot match the session the merchant knows about.
    784 STATUS=$(redeem "$WEBSITE" $((EXPIRATION + 86400)) "$NONCE" "$ORDER_ID" "$SCRATCH/x.hdr")
    785 if [ "$STATUS" = "303" ]
    786 then
    787     fail "the order was redeemed for a later expiration than it paid for"
    788 else
    789     ok "redeeming for a later expiration is refused ($STATUS)"
    790 fi
    791 
    792 # --------------------------------------------------------------------
    793 # What the cookie is worth.
    794 # --------------------------------------------------------------------
    795 
    796 echo "-- after payment --"
    797 
    798 STATUS=$(curl -s -D "$SCRATCH/paid.hdr" -o "$SCRATCH/paid.body" \
    799               -w "%{http_code}" -H "Cookie: $COOKIE" "$WEBSITE")
    800 if [ "$STATUS" = "200" ] && grep -qi '^x-upstream:' "$SCRATCH/paid.hdr"
    801 then
    802     ok "the paid URL is served from the origin"
    803 else
    804     fail "the paid URL answered $STATUS, and not from the origin"
    805 fi
    806 
    807 if grep -q "Hello" "$SCRATCH/paid.body"
    808 then
    809     ok "the origin's body arrives intact"
    810 else
    811     fail "the origin's body is not what it serves directly"
    812 fi
    813 
    814 STATUS=$(curl -s -o /dev/null -w "%{http_code}" -H "Cookie: $COOKIE" "${BASE_URL}item")
    815 if [ "$STATUS" = "302" ]
    816 then
    817     ok "the cookie does not open a URL it was not minted for"
    818 else
    819     fail "the cookie opened /item as well: $STATUS"
    820 fi
    821 
    822 STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$WEBSITE")
    823 if [ "$STATUS" = "302" ]
    824 then
    825     ok "without the cookie the same URL is still paywalled"
    826 else
    827     fail "the paid URL is open to everyone: $STATUS"
    828 fi
    829 
    830 # The expiration is the KDF salt, so rewriting it in the value has to
    831 # break the MAC rather than extend the access.
    832 LATER=$(( EXPIRATION + 86400 ))
    833 TAMPERED="Paivana-Cookie=${LATER}-${COOKIE#*-}"
    834 STATUS=$(curl -s -o /dev/null -w "%{http_code}" -H "Cookie: $TAMPERED" "$WEBSITE")
    835 if [ "$STATUS" = "302" ]
    836 then
    837     ok "rewriting the expiration in the cookie invalidates it"
    838 else
    839     fail "a cookie with a rewritten expiration was accepted: $STATUS"
    840 fi
    841 
    842 STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
    843               -H "Cookie: Paivana-Cookie=not-a-cookie" "$WEBSITE")
    844 if [ "$STATUS" = "302" ]
    845 then
    846     ok "a malformed cookie is refused rather than mis-parsed"
    847 else
    848     fail "a malformed cookie answered $STATUS"
    849 fi
    850 
    851 # One payment can be redeemed again, from anywhere: this is deliberate
    852 # (design document 076, "Payment buys access, not a seat") and the check
    853 # is here so that a change of mind about it shows up as a test failure
    854 # rather than as a silent change of policy.
    855 STATUS=$(redeem "$WEBSITE" "$EXPIRATION" "$NONCE" "$ORDER_ID" "$SCRATCH/again.hdr")
    856 if [ "$STATUS" = "303" ]
    857 then
    858     ok "the redemption is repeatable, as the design document says it is"
    859 else
    860     fail "redeeming a second time gave $STATUS; if that is intended, 076 needs updating"
    861 fi
    862 
    863 echo "=== $CHECKS checks, $FAILS failure(s) ==="
    864 if [ "$FAILS" -gt 0 ]
    865 then
    866     exit 1
    867 fi
    868 echo "=== all paywall tests passed ==="
    869 exit 0