paivana

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

test_paywall.sh (28223B)


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