commit 9ec86ab5a3b3d90aca53c39bd75497f1ad0f933e
parent 45902366f69d365cedc0259ac390327506fc6d94
Author: Christian Grothoff <christian@grothoff.org>
Date: Fri, 7 Aug 2026 18:54:45 +0200
expand test suite with payment test
Diffstat:
7 files changed, 1210 insertions(+), 5 deletions(-)
diff --git a/.gitignore b/.gitignore
@@ -12,3 +12,4 @@ build/
# 'nix build' result symlink
result
+src/tests/test_paywall.conf.edited
diff --git a/src/tests/README b/src/tests/README
@@ -1,10 +1,12 @@
paivana tests
=============
-This directory contains four test programs:
+This directory contains five test programs:
reverse_proxy an integration suite for the reverse-proxy side of
paivana-httpd, driven by test_reverse_proxy.sh
+ paywall an integration suite for the paywall itself, driven
+ by test_paywall.sh against a real GNU Taler system
client_address a unit test for the client address the access
cookie is keyed on (test_client_address.c)
cookie_header a unit test for the `Set-Cookie` line paivana emits
@@ -13,10 +15,13 @@ This directory contains four test programs:
value encodes, and for the `paivana_id` the order
is created under (test_cookie_access.c)
-The integration suite runs paivana-httpd with `-n` (paywall disabled)
-so no merchant backend is required: it only verifies that the proxy
-correctly forwards HTTP requests and responses. Everything below
-describes that suite except the three unit-test sections at the end.
+The reverse-proxy suite runs paivana-httpd with `-n` (paywall
+disabled) so no merchant backend is required: it only verifies that the
+proxy correctly forwards HTTP requests and responses. The paywall
+suite is the other half -- everything `-n` switches off -- and needs an
+exchange, a merchant backend and a bank, so it skips where those are
+not installed. Everything below describes the reverse-proxy suite
+except the sections at the end.
What gets built
---------------
@@ -490,3 +495,80 @@ Every response also carries an `X-Upstream:` header whose value
identifies which server handled it (mhd, go, py, rs); the client
test cases use it to confirm that responses are coming back from
the expected backend.
+
+
+The paywall suite
+-----------------
+
+`test_paywall.sh` covers what `-n` hides. It puts a real GNU Taler
+system behind paivana-httpd -- a fakebank, an exchange and a merchant
+backend, started with `taler-unified-setup.sh` exactly as the merchant
+and anastasis suites start theirs -- creates a Paivana template on the
+merchant instance, buys access with `taler-wallet-cli`, and checks what
+the daemon does with the result. 31 checks, about 25 seconds.
+
+It skips (exit 77) rather than failing when the environment cannot
+support it: no `taler-unified-setup.sh`, `taler-wallet-cli`,
+`taler-merchant-httpd`, `jq`, `python3` or PostgreSQL, no built paywall
+template, or one of its ports already in use. A skip names what was
+missing.
+
+Ports. paivana's own two move with `PAIVANA_PORT_BASE` (+110 and
++111), but the Taler system's are fixed at 9966 (merchant), 8081
+(exchange) and 8082 (bank) -- the same ones the merchant suite uses, so
+the two cannot run at once and this suite skips when they are busy. It
+also wants a PostgreSQL database named `paivanacheck`, which it creates
+if it can; `talercheck` is deliberately not reused, since the merchant's
+own tests would then be clobbering these tables and vice versa.
+
+The paywall template is staged into a throwaway prefix and reached
+through `PAIVANA_PREFIX`, so a build tree is enough and `make install`
+is not required.
+
+Why the client half is written out by hand. The paywall page computes
+a payment identifier from (nonce, website, expiration) and the daemon
+computes the same identifier independently; neither ever sends it to
+the other, so the two agreeing IS the protocol. `paivana_id.py`
+re-derives it -- and the Crockford base32 encoding of the nonce -- from
+the definition `src/frontend/paywall.js` implements, which is what makes
+this a test of both ends rather than of one end twice. It agrees with
+the golden vector in `test_cookie_access.c`, which was computed the same
+way; if you change the derivation, three places have to move together.
+
+What it covers, in order:
+
+ * the unpaid path: 302 to the paywall, the template named in the
+ Location and the website base64url-encoded in the fragment, the
+ 402 page itself, its `Paivana:` pay-template URI and its CSP;
+ * the whitelist, and specifically that a WHITELIST expression is
+ anchored at both ends -- `/echo-headers` waives that path and not
+ `/x/echo-headers` or `/echo-headers/x`. The regexec that decides
+ this sits behind the paywall, so no other test in the tree can
+ reach it;
+ * the redemption endpoint's refusals: a body missing its fields, a
+ nonce of the wrong length, an order the merchant never saw;
+ * a real payment, redeemed for a real access cookie, and that cookie
+ opening the URL it was minted for and no other;
+ * that rewriting the expiration in the cookie value invalidates it
+ (the expiration is the KDF salt) and that a malformed cookie is
+ refused rather than mis-parsed;
+ * an order bought for a DIFFERENT fulfillment URL under the session
+ we then claim. The merchant sells it, the session lookup succeeds,
+ and the only thing between that and a cookie for a page nobody paid
+ for is paivana comparing the contract's fulfillment URL against the
+ website claimed. This is the one case that reaches that comparison:
+ naming another website in the redemption changes the payment
+ identifier, so every simpler attempt is refused earlier, by the
+ session lookup;
+ * that redemption is repeatable from anywhere, which is deliberate
+ (design document 076, "Payment buys access, not a seat"). The
+ check is here so that a change of mind about it surfaces as a test
+ failure rather than as a silent change of policy.
+
+The checks were verified not to be vacuous by breaking the code under
+them, one property at a time: dropping the `^(...)$` wrapping around
+WHITELIST turns the two anchoring cases red (`/x/echo-headers` reaches
+the origin); dropping the website from the cookie's keyed hash lets the
+paid cookie open `/item` as well; and skipping the fulfillment-URL
+comparison lets an order bought for `/elsewhere` mint a cookie for
+`/item`.
diff --git a/src/tests/meson.build b/src/tests/meson.build
@@ -143,3 +143,20 @@ test(
depends: test_deps,
timeout: 300,
)
+
+# The paywall against a real merchant backend. Skips (77) unless the
+# GNU Taler stack and PostgreSQL are there, since nothing in a bare
+# paivana checkout can bring an exchange and a merchant up. Given a
+# long timeout because it withdraws coins and makes a payment: the
+# wallet's withdrawal alone is tens of seconds.
+test(
+ 'paywall',
+ files('test_paywall.sh'),
+ env: {
+ 'SRCDIR': meson.current_source_dir(),
+ 'BUILDDIR': meson.current_build_dir(),
+ 'PAIVANA_HTTPD': paivana_httpd_exe.full_path(),
+ },
+ depends: test_deps,
+ timeout: 900,
+)
diff --git a/src/tests/paivana_id.py b/src/tests/paivana_id.py
@@ -0,0 +1,61 @@
+#!/usr/bin/env python3
+"""Recompute what the paywall page computes, independently of paivana.
+
+This mirrors src/frontend/paywall.js -- makePaivanaId(),
+timestampRoundedToBuffer() and encodeCrock() -- because agreement between
+the two ends IS the protocol: neither side ever sends the paivana ID to
+the other, each derives it from its own copy of the inputs.
+
+Usage: paivana_id.py <expiration-seconds> <website> [<nonce-hex>]
+Prints: <nonce-crockford> <paivana-id>
+"""
+import base64
+import hashlib
+import os
+import struct
+import sys
+
+# GNUnet's Crockford base32 alphabet (gnunet strings.c), which is what
+# GNUNET_STRINGS_string_to_data() reads back on the daemon side.
+ENC_TABLE = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
+
+
+def encode_crock(data: bytes) -> str:
+ out = []
+ bit_buf = 0
+ num_bits = 0
+ pos = 0
+ size = len(data)
+ while pos < size or num_bits > 0:
+ if pos < size and num_bits < 5:
+ bit_buf = (bit_buf << 8) | data[pos]
+ pos += 1
+ num_bits += 8
+ if num_bits < 5:
+ bit_buf <<= 5 - num_bits
+ num_bits = 5
+ out.append(ENC_TABLE[(bit_buf >> (num_bits - 5)) & 31])
+ num_bits -= 5
+ return "".join(out)
+
+
+def make_paivana_id(cur_time: int, nonce: bytes, website: str) -> str:
+ buf = nonce + website.encode("utf-8") + b"\0" + struct.pack(">Q", cur_time * 1000 * 1000)
+ digest = hashlib.sha256(buf).digest()
+ tail = base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=")
+ return f"{cur_time}-{tail}"
+
+
+def main() -> int:
+ if len(sys.argv) not in (3, 4):
+ sys.stderr.write(__doc__)
+ return 2
+ cur_time = int(sys.argv[1])
+ website = sys.argv[2]
+ nonce = bytes.fromhex(sys.argv[3]) if len(sys.argv) == 4 else os.urandom(16)
+ print(f"{encode_crock(nonce)} {make_paivana_id(cur_time, nonce, website)}")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/src/tests/setup.sh b/src/tests/setup.sh
@@ -0,0 +1,90 @@
+# This file is in the public domain.
+#
+# Inlined into test scripts that need a live GNU Taler system. Defines
+# setup(), which wraps `taler-unified-setup.sh': it launches the services,
+# waits until they report READY, and installs an exit handler that stops
+# them again. Pass setup() the arguments for taler-unified-setup.sh.
+#
+# This is the same wrapper the merchant and anastasis test suites use.
+# Keep it in step with them rather than improving it here.
+
+set -eu
+
+# The Taler services write into TALER_TEST_HOME (see test_paywall.conf);
+# an XDG variable inherited from the caller would send parts of them
+# somewhere else and the run would use a mix of the two.
+unset XDG_DATA_HOME
+unset XDG_CONFIG_HOME
+
+
+# Exit with the meson/automake "skip" status. A missing component is an
+# environment problem, not a regression, and must not fail the suite.
+function exit_skip() {
+ echo "SKIPPING: $*" >&2
+ exit 77
+}
+
+function exit_fail() {
+ echo "$@" >&2
+ exit 1
+}
+
+# Cleanup to run whenever we exit
+function exit_cleanup()
+{
+ if [ ! -z ${SETUP_PID+x} ]
+ then
+ echo "Killing taler-unified-setup ($SETUP_PID)" >&2
+ kill -TERM "$SETUP_PID" 2> /dev/null || true
+ wait "$SETUP_PID" 2> /dev/null || true
+ fi
+}
+
+# Install cleanup handler (except for kill -9)
+trap exit_cleanup EXIT
+
+function setup()
+{
+ echo "Starting test system ..." >&2
+ # Create a named pipe in a temp directory we own.
+ FIFO_DIR=$(mktemp -p "${TMPDIR:-/tmp}" -d fifo-XXXXXX)
+ FIFO_OUT="$FIFO_DIR/out"
+ mkfifo "$FIFO_OUT"
+ # Open pipe as FD 3 (RW) and FD 4 (RO)
+ exec 3<> "$FIFO_OUT" 4< "$FIFO_OUT"
+ rm -rf "$FIFO_DIR"
+ # We require '-W' for our termination logic to work.
+ taler-unified-setup.sh -W "$@" >&3 &
+ SETUP_PID=$!
+ # Close FD3
+ exec 3>&-
+ # Pass the setup's output through until it announces READY:. Note that
+ # reaching end-of-pipe is NOT success: that is what happens when
+ # taler-unified-setup.sh dies during startup, and continuing then makes
+ # the test fail much later against a system that was never brought up,
+ # hiding the actual error. So insist on having seen the marker.
+ SETUP_READY=0
+ while IFS= read -r line <&4
+ do
+ printf '%s\n' "$line"
+ case "$line" in
+ *READY:*)
+ SETUP_READY=1
+ break
+ ;;
+ esac
+ done
+ # Close FD4
+ exec 4>&-
+ if [ "1" != "$SETUP_READY" ]
+ then
+ wait "$SETUP_PID" || SETUP_STATUS=$?
+ unset SETUP_PID
+ if [ "${SETUP_STATUS:-1}" = "77" ]
+ then
+ exit_skip "taler-unified-setup.sh could not launch the test system"
+ fi
+ exit_fail "taler-unified-setup.sh died with status ${SETUP_STATUS:-?} before the test system was ready"
+ fi
+ echo "Test system ready" >&2
+}
diff --git a/src/tests/test_paywall.conf b/src/tests/test_paywall.conf
@@ -0,0 +1,193 @@
+# This file is in the public domain.
+#
+# Configuration for the GNU Taler system that test_paywall.sh starts with
+# taler-unified-setup.sh: a fakebank, an exchange and a merchant backend,
+# in TESTKUDOS. It is deliberately close to merchant's
+# src/testing/test_template.conf, which is the configuration the merchant
+# project tests its own paivana templates against.
+#
+# paivana-httpd is NOT configured here. Its [paivana] section is written
+# at run time into the scratch directory, because the ports it listens on
+# move with PAIVANA_PORT_BASE while the ports below are fixed -- the same
+# ones the merchant suite uses, so the two cannot run at once. The test
+# checks them first and skips rather than failing.
+[PATHS]
+TALER_TEST_HOME = test_paywall_home/
+
+[exchange]
+CURRENCY = TESTKUDOS
+CURRENCY_ROUND_UNIT = TESTKUDOS:0.01
+TINY_AMOUNT = TESTKUDOS:0.01
+AML_THRESHOLD = TESTKUDOS:1000000
+MAX_KEYS_CACHING = forever
+DB = postgres
+MASTER_PUBLIC_KEY = KHA6YSPRQV1ZFCF144SY8KJNR588XA8DA0F6510FKJW30DJFJNAG
+SERVE = tcp
+UNIXPATH = ${TALER_RUNTIME_DIR}/exchange.http
+UNIXPATH_MODE = 660
+PORT = 8081
+BASE_URL = http://localhost:8081/
+SIGNKEY_DURATION = 2 weeks
+SIGNKEY_LEGAL_DURATION = 2 years
+LOOKAHEAD_SIGN = 3 weeks 1 day
+KEYDIR = ${TALER_DATA_HOME}/exchange/live-keys/
+REVOCATION_DIR = ${TALER_DATA_HOME}/exchange/revocations/
+TERMS_ETAG = 0
+PRIVACY_ETAG = 0
+
+[exchangedb-postgres]
+CONFIG = postgres:///paivanacheck
+
+[merchant]
+SERVE = tcp
+PORT = 9966
+PAYMENT_TARGET_TYPES = *
+CURRENCY = TESTKUDOS
+BASE_URL = http://localhost:9966/
+UNIXPATH = ${TALER_RUNTIME_DIR}/merchant.http
+UNIXPATH_MODE = 660
+DEFAULT_WIRE_FEE_AMORTIZATION = 1
+DB = postgres
+WIREFORMAT = default
+WIRE_TRANSFER_DELAY = 1 minute
+DEFAULT_PAY_DEADLINE = 1 day
+DEFAULT_MAX_DEPOSIT_FEE = TESTKUDOS:0.1
+KEYFILE = ${TALER_DATA_HOME}/merchant/merchant.priv
+DEFAULT_MAX_WIRE_FEE = TESTKUDOS:0.10
+FORCE_AUDIT = YES
+
+[merchantdb-postgres]
+CONFIG = postgres:///paivanacheck
+SQL_DIR = ${DATADIR}sql/
+
+[bank]
+HTTP_PORT = 8082
+
+[auditor]
+DB = postgres
+AUDITOR_PRIV_FILE = ${TALER_DATA_HOME}/auditor/offline-keys/auditor.priv
+SERVE = tcp
+UNIXPATH = ${TALER_RUNTIME_DIR}/exchange.http
+UNIXPATH_MODE = 660
+PORT = 8083
+AUDITOR_URL = http://localhost:8083/
+TINY_AMOUNT = TESTKUDOS:0.01
+BASE_URL = "http://localhost:8083/"
+
+[exchangedb]
+IDLE_RESERVE_EXPIRATION_TIME = 4 weeks
+LEGAL_RESERVE_EXPIRATION_TIME = 7 years
+
+[exchange-account-2]
+PAYTO_URI = "payto://x-taler-bank/localhost/exchange?receiver-name=exchange"
+ENABLE_DEBIT = YES
+ENABLE_CREDIT = YES
+
+[exchange-accountcredentials-2]
+WIRE_GATEWAY_AUTH_METHOD = none
+WIRE_GATEWAY_URL = "http://localhost:8082/accounts/exchange/taler-wire-gateway/"
+
+[admin-accountcredentials-2]
+WIRE_GATEWAY_AUTH_METHOD = none
+WIRE_GATEWAY_URL = "http://localhost:8082/accounts/exchange/taler-wire-gateway/"
+
+[merchant-exchange-default]
+EXCHANGE_BASE_URL = http://localhost:8081/
+CURRENCY = TESTKUDOS
+MASTER_KEY = KHA6YSPRQV1ZFCF144SY8KJNR588XA8DA0F6510FKJW30DJFJNAG
+
+[coin_kudos_ct_1]
+value = TESTKUDOS:0.01
+duration_withdraw = 7 days
+duration_spend = 2 years
+duration_legal = 3 years
+fee_withdraw = TESTKUDOS:0.01
+fee_deposit = TESTKUDOS:0.01
+fee_refresh = TESTKUDOS:0.01
+fee_refund = TESTKUDOS:0.01
+rsa_keysize = 1024
+CIPHER = RSA
+
+[coin_kudos_ct_10]
+value = TESTKUDOS:0.10
+duration_withdraw = 7 days
+duration_spend = 2 years
+duration_legal = 3 years
+fee_withdraw = TESTKUDOS:0.01
+fee_deposit = TESTKUDOS:0.01
+fee_refresh = TESTKUDOS:0.03
+fee_refund = TESTKUDOS:0.01
+rsa_keysize = 1024
+CIPHER = RSA
+
+[coin_kudos_1]
+value = TESTKUDOS:1
+duration_withdraw = 7 days
+duration_spend = 2 years
+duration_legal = 3 years
+fee_withdraw = TESTKUDOS:0.02
+fee_deposit = TESTKUDOS:0.02
+fee_refresh = TESTKUDOS:0.03
+fee_refund = TESTKUDOS:0.01
+rsa_keysize = 1024
+CIPHER = RSA
+
+[coin_kudos_2]
+value = TESTKUDOS:2
+duration_withdraw = 7 days
+duration_spend = 2 years
+duration_legal = 3 years
+fee_withdraw = TESTKUDOS:0.03
+fee_deposit = TESTKUDOS:0.03
+fee_refresh = TESTKUDOS:0.04
+fee_refund = TESTKUDOS:0.02
+rsa_keysize = 1024
+CIPHER = RSA
+
+[coin_kudos_4]
+value = TESTKUDOS:4
+duration_withdraw = 7 days
+duration_spend = 2 years
+duration_legal = 3 years
+fee_withdraw = TESTKUDOS:0.03
+fee_deposit = TESTKUDOS:0.03
+fee_refresh = TESTKUDOS:0.04
+fee_refund = TESTKUDOS:0.02
+rsa_keysize = 1024
+CIPHER = RSA
+
+[coin_kudos_5]
+value = TESTKUDOS:5
+duration_withdraw = 7 days
+duration_spend = 2 years
+duration_legal = 3 years
+fee_withdraw = TESTKUDOS:0.01
+fee_deposit = TESTKUDOS:0.01
+fee_refresh = TESTKUDOS:0.03
+fee_refund = TESTKUDOS:0.01
+rsa_keysize = 1024
+CIPHER = RSA
+
+[coin_kudos_8]
+value = TESTKUDOS:8
+duration_withdraw = 7 days
+duration_spend = 2 years
+duration_legal = 3 years
+fee_withdraw = TESTKUDOS:0.05
+fee_deposit = TESTKUDOS:0.02
+fee_refresh = TESTKUDOS:0.03
+fee_refund = TESTKUDOS:0.04
+rsa_keysize = 1024
+CIPHER = RSA
+
+[coin_kudos_10]
+value = TESTKUDOS:10
+duration_withdraw = 7 days
+duration_spend = 2 years
+duration_legal = 3 years
+fee_withdraw = TESTKUDOS:0.01
+fee_deposit = TESTKUDOS:0.01
+fee_refresh = TESTKUDOS:0.03
+fee_refund = TESTKUDOS:0.01
+rsa_keysize = 1024
+CIPHER = RSA
diff --git a/src/tests/test_paywall.sh b/src/tests/test_paywall.sh
@@ -0,0 +1,761 @@
+#!/usr/bin/env bash
+# This file is part of GNU Taler
+# Copyright (C) 2025, 2026 Taler Systems SA
+#
+# GNU Taler is free software; you can redistribute it and/or modify it under
+# the terms of the GNU Affero General Public License as published by the Free
+# Software Foundation; either version 3, or (at your option) any later
+# version.
+#
+# GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for
+# more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with GNU Taler; see the file COPYING. If not, see
+# <http://www.gnu.org/licenses/>
+#
+#
+# The paywall, against a real merchant backend.
+#
+# test_reverse_proxy.sh runs paivana-httpd with `-n', which switches the
+# paywall off before the first byte of a request is looked at, so it
+# exercises the proxy and nothing else. Everything paivana exists for --
+# the paywall page, the payment, the access cookie -- is on the other side
+# of that flag and had no automated coverage at all.
+#
+# This test puts a real GNU Taler system behind paivana-httpd with
+# taler-unified-setup.sh (a fakebank, an exchange and a merchant backend,
+# the same way merchant and anastasis test their own payment paths), buys
+# access with taler-wallet-cli, and checks what the daemon does with it.
+#
+# The client half is written out by hand rather than driven through a
+# browser: the paywall page's JavaScript computes a payment identifier from
+# (nonce, website, expiration) and the daemon computes the same identifier
+# independently, so the two agreeing IS the protocol -- it is never sent
+# from one to the other. paivana_id.py re-derives it from the definition
+# src/frontend/paywall.js implements, which is what makes this a test of
+# both ends rather than of one end twice.
+
+set -eu
+
+# shellcheck source=src/tests/setup.sh
+. "$(cd -- "$(dirname -- "$0")" && pwd)/setup.sh"
+
+# Ports of the Taler system come from test_paywall.conf and are fixed;
+# paivana's own move as a block, exactly as in test_reverse_proxy.sh.
+PORT_BASE="${PAIVANA_PORT_BASE:-18400}"
+PAIVANA_PORT=$((PORT_BASE + 110))
+ORIGIN_PORT=$((PORT_BASE + 111))
+MERCHANT_PORT=9966
+EXCHANGE_PORT=8081
+BANK_PORT=8082
+
+function here() {
+ cd -- "$(dirname -- "$0")" && pwd
+}
+
+# Same convention as test_reverse_proxy.sh: meson passes both, and the
+# defaults let the script be run by hand from either tree.
+SRCDIR="${SRCDIR:-$(here)}"
+BUILDDIR="${BUILDDIR:-$PWD}"
+
+PAIVANA_HTTPD="${PAIVANA_HTTPD:-$BUILDDIR/../backend/paivana-httpd}"
+UPSTREAM_MHD="${UPSTREAM_MHD:-$BUILDDIR/upstream_mhd}"
+
+CHECKS=0
+FAILS=0
+
+function ok() {
+ CHECKS=$((CHECKS + 1))
+ echo " OK: $1"
+}
+
+function fail() {
+ CHECKS=$((CHECKS + 1))
+ FAILS=$((FAILS + 1))
+ printf ' FAIL: %b\n' "$1" >&2
+}
+
+# Is a TCP port free? Same probe as test_reverse_proxy.sh: bash's
+# /dev/tcp in a subshell, so a failed connect cannot kill the script.
+function port_is_free() {
+ ! (exec 3<>"/dev/tcp/127.0.0.1/$1") 2>/dev/null
+}
+
+function wait_for_port() {
+ local port="$1" pid="${2:-}" tries=100
+ while [ "$tries" -gt 0 ]
+ do
+ if ! port_is_free "$port"
+ then
+ return 0
+ fi
+ # Probe first: a child that handed the listening socket on and
+ # exited is still a service that came up.
+ if [ -n "$pid" ] && ! kill -0 "$pid" 2>/dev/null
+ then
+ return 1
+ fi
+ sleep 0.1
+ tries=$((tries - 1))
+ done
+ return 1
+}
+
+# --------------------------------------------------------------------
+# Preconditions. Every one of these is an environment problem rather
+# than a regression, so each exits 77 naming what is missing.
+# --------------------------------------------------------------------
+
+echo -n "Checking for curl ..."
+curl --version >/dev/null 2>&1 || exit_skip "no curl"
+echo " OK"
+echo -n "Checking for jq ..."
+jq --version >/dev/null 2>&1 || exit_skip "no jq"
+echo " OK"
+echo -n "Checking for python3 ..."
+python3 --version >/dev/null 2>&1 || exit_skip "no python3"
+echo " OK"
+echo -n "Checking for taler-unified-setup.sh ..."
+command -v taler-unified-setup.sh >/dev/null 2>&1 \
+ || exit_skip "no taler-unified-setup.sh (the Taler stack is not installed)"
+echo " OK"
+echo -n "Checking for taler-wallet-cli ..."
+command -v taler-wallet-cli >/dev/null 2>&1 || exit_skip "no taler-wallet-cli"
+echo " OK"
+echo -n "Checking for taler-merchant-httpd ..."
+command -v taler-merchant-httpd >/dev/null 2>&1 || exit_skip "no taler-merchant-httpd"
+echo " OK"
+echo -n "Checking for PostgreSQL ..."
+pg_isready >/dev/null 2>&1 || exit_skip "PostgreSQL is not accepting connections"
+echo " OK"
+echo -n "Checking for paivana-httpd ..."
+[ -x "$PAIVANA_HTTPD" ] || exit_skip "no paivana-httpd at $PAIVANA_HTTPD"
+echo " OK"
+echo -n "Checking for the test upstream ..."
+[ -x "$UPSTREAM_MHD" ] || exit_skip "no upstream_mhd at $UPSTREAM_MHD"
+echo " OK"
+
+# The paywall page is a template paivana loads from its installation
+# prefix, so a build tree alone is not enough. Rather than demanding
+# `make install', stage the one file into a prefix of our own and point
+# PAIVANA_PREFIX at it -- the same variable paivana's project data
+# declares for exactly this.
+PAYWALL_TEMPLATE=""
+for cand in "$BUILDDIR/../frontend/paywall.en.must" \
+ "$SRCDIR/../frontend/paywall.en.must"
+do
+ if [ -r "$cand" ]
+ then
+ PAYWALL_TEMPLATE="$cand"
+ break
+ fi
+done
+if [ -z "$PAYWALL_TEMPLATE" ]
+then
+ exit_skip "the paywall template has not been built"
+fi
+
+echo -n "Checking that the ports are free ..."
+BUSY=""
+for p in "$PAIVANA_PORT" "$ORIGIN_PORT" "$MERCHANT_PORT" "$EXCHANGE_PORT" "$BANK_PORT"
+do
+ port_is_free "$p" || BUSY="$BUSY $p"
+done
+if [ -n "$BUSY" ]
+then
+ # The Taler ports are the ones the merchant suite uses, so this is
+ # most often another Taler test running, not a leftover. paivana's
+ # own two move with PAIVANA_PORT_BASE.
+ exit_skip "port(s) already in use:$BUSY"
+fi
+echo " OK"
+
+# --------------------------------------------------------------------
+# Scratch space. NOT named TMPDIR: that is the variable every child
+# reads for its own temporary files, and cleanup removes this directory
+# while the Taler services may still be running out of it.
+# --------------------------------------------------------------------
+
+SCRATCH="$(mktemp -d -t paivana-paywall.XXXXXX)"
+PIDS=""
+
+function stop_children() {
+ local pid
+ for pid in $PIDS
+ do
+ kill -TERM "$pid" 2>/dev/null || true
+ done
+ for pid in $PIDS
+ do
+ wait "$pid" 2>/dev/null || true
+ done
+ PIDS=""
+}
+
+function paywall_cleanup() {
+ stop_children
+ # exit_cleanup (from setup.sh) stops taler-unified-setup.sh.
+ exit_cleanup
+ if [ -z "${KEEP_TMP:-}" ]
+ then
+ rm -rf "$SCRATCH"
+ else
+ echo "Keeping $SCRATCH" >&2
+ fi
+}
+trap paywall_cleanup EXIT
+
+mkdir -p "$SCRATCH/prefix/share/paivana/templates"
+cp "$PAYWALL_TEMPLATE" "$SCRATCH/prefix/share/paivana/templates/"
+export PAIVANA_PREFIX="$SCRATCH/prefix/"
+
+# --------------------------------------------------------------------
+# The Taler system.
+# --------------------------------------------------------------------
+
+CONF="$SRCDIR/test_paywall.conf"
+[ -r "$CONF" ] || exit_fail "cannot read $CONF"
+
+# taler-unified-setup.sh resolves TALER_TEST_HOME relative to the working
+# directory and writes into it, so run it from the scratch directory with
+# an absolute path to the configuration.
+CONF="$(cd "$(dirname "$CONF")" && pwd)/$(basename "$CONF")"
+cd "$SCRATCH"
+
+# Give the exchange and the merchant a database of their own. Reusing
+# `talercheck' would have this test and the merchant's own tests
+# clobbering each other's tables.
+DB=paivanacheck
+if ! psql -Aqt -c "SELECT 1" "$DB" >/dev/null 2>&1
+then
+ createdb "$DB" >/dev/null 2>&1 \
+ || exit_skip "cannot create the PostgreSQL database $DB"
+fi
+
+setup -c "$CONF" \
+ -r "merchant-exchange-default" \
+ -em \
+ -f -d x-taler-bank -u exchange-account-2
+
+MERCHANT_URL="http://localhost:$MERCHANT_PORT/"
+AUTH="Authorization: Bearer secret-token:super_secret"
+RESP="$SCRATCH/response.json"
+
+function merchant_post() {
+ local path="$1" body="$2"
+ curl -s -o "$RESP" -w "%{http_code}" \
+ -H "Content-Type: application/json" \
+ -H "$AUTH" \
+ -X POST \
+ "${MERCHANT_URL}${path}" \
+ -d "$body"
+}
+
+echo -n "Configuring merchant instance ..."
+STATUS=$(merchant_post management/instances \
+ '{"auth":{"method":"token","token":"secret-token:super_secret"},
+ "id":"admin","name":"default","user_type":"business",
+ "address":{},"jurisdiction":{},"use_stefan":true,
+ "default_wire_transfer_delay":{"d_us":50000000000},
+ "default_pay_delay":{"d_us":60000000000}}')
+[ "$STATUS" = "204" ] || exit_fail "creating the instance: got $STATUS, $(cat "$RESP")"
+echo " OK"
+
+echo -n "Configuring merchant bank account ..."
+STATUS=$(merchant_post private/accounts \
+ '{"payto_uri":"payto://x-taler-bank/localhost/fortythree?receiver-name=fortythree"}')
+[ "$STATUS" = "200" ] || exit_fail "adding the account: got $STATUS, $(cat "$RESP")"
+echo " OK"
+
+# website_regex is `.*' so that every URL is paywalled and WHITELIST is
+# the only exemption; that is what lets the whitelist cases below mean
+# what they say. With a narrower expression a URL matching no template
+# is served for free, which would make a whitelist miss indistinguishable
+# from a template miss.
+echo -n "Creating the Paivana template ..."
+STATUS=$(merchant_post private/templates \
+ '{"template_id":"premium",
+ "template_description":"Paywalled content",
+ "template_contract":{"template_type":"paivana",
+ "summary":"Access to the article",
+ "website_regex":".*",
+ "max_pickup_duration":{"d_us":3600000000},
+ "choices":[{"amount":"TESTKUDOS:1"}]}}')
+[ "$STATUS" = "204" ] || exit_fail "creating the template: got $STATUS, $(cat "$RESP")"
+echo " OK"
+
+# --------------------------------------------------------------------
+# The origin and paivana itself.
+# --------------------------------------------------------------------
+
+BASE_URL="http://localhost:$PAIVANA_PORT/"
+
+cat > "$SCRATCH/paivana.conf" <<EOF
+[paivana]
+DESTINATION_BASE_URL = http://localhost:$ORIGIN_PORT/
+BASE_URL = $BASE_URL
+MERCHANT_BACKEND_URL = $MERCHANT_URL
+MERCHANT_ACCESS_TOKEN = secret-token:super_secret
+# A fixed secret so that a cookie minted here can be reasoned about; a
+# real deployment must not do this (see the manual on SECRET).
+SECRET = paivana-integration-test-secret
+WHITELIST = /echo-headers|/large/.*
+SERVE = tcp
+PORT = $PAIVANA_PORT
+EOF
+
+echo -n "Starting the origin ..."
+"$UPSTREAM_MHD" "$ORIGIN_PORT" > "$SCRATCH/origin.log" 2>&1 &
+ORIGIN_PID=$!
+PIDS="$PIDS $ORIGIN_PID"
+wait_for_port "$ORIGIN_PORT" "$ORIGIN_PID" \
+ || exit_fail "the test origin did not start on port $ORIGIN_PORT"
+echo " OK"
+
+echo -n "Starting paivana-httpd ..."
+"$PAIVANA_HTTPD" -c "$SCRATCH/paivana.conf" -L INFO > "$SCRATCH/paivana.log" 2>&1 &
+PAIVANA_PID=$!
+PIDS="$PIDS $PAIVANA_PID"
+# paivana does not listen until it has fetched the templates, so this
+# also asserts that the merchant handed them over.
+wait_for_port "$PAIVANA_PORT" "$PAIVANA_PID" \
+ || exit_fail "paivana-httpd did not start:\n$(cat "$SCRATCH/paivana.log")"
+echo " OK"
+
+# --------------------------------------------------------------------
+# What the paywall does before anyone has paid.
+# --------------------------------------------------------------------
+
+echo "-- before payment --"
+
+STATUS=$(curl -s -o /dev/null -w "%{http_code}" "${BASE_URL}hello")
+if [ "$STATUS" = "302" ]
+then
+ ok "an unpaid URL is redirected to the paywall"
+else
+ fail "an unpaid URL should be 302, got $STATUS"
+fi
+
+curl -s -D "$SCRATCH/redirect.hdr" -o /dev/null "${BASE_URL}hello"
+LOCATION=$(grep -i '^location:' "$SCRATCH/redirect.hdr" | tr -d '\r' | cut -d' ' -f2-)
+case "$LOCATION" in
+ *"/.well-known/paivana/templates/premium#"*)
+ ok "the redirect names the template and carries the website in the fragment"
+ ;;
+ *)
+ fail "unexpected paywall redirect: $LOCATION"
+ ;;
+esac
+
+# The fragment is the website, base64url-encoded; the page's JavaScript
+# atob()s it, so a '-' or '_' in it has to survive.
+FRAGMENT="${LOCATION#*#}"
+DECODED=$(python3 -c '
+import base64, sys
+s = sys.argv[1]
+print(base64.urlsafe_b64decode(s + "=" * (-len(s) % 4)).decode("utf-8"))' "$FRAGMENT")
+if [ "$DECODED" = "${BASE_URL}hello" ]
+then
+ ok "the fragment decodes to the URL that was asked for"
+else
+ fail "fragment decodes to '$DECODED', want '${BASE_URL}hello'"
+fi
+
+PAGE_STATUS=$(curl -s -D "$SCRATCH/page.hdr" -o "$SCRATCH/page.html" \
+ -w "%{http_code}" \
+ "${BASE_URL}.well-known/paivana/templates/premium")
+if [ "$PAGE_STATUS" = "402" ]
+then
+ ok "the paywall page is served with 402 Payment Required"
+else
+ fail "the paywall page should be 402, got $PAGE_STATUS"
+fi
+
+PAIVANA_HEADER=$(grep -i '^paivana:' "$SCRATCH/page.hdr" | tr -d '\r' | cut -d' ' -f2-)
+case "$PAIVANA_HEADER" in
+ "taler+http://pay-template/localhost:$MERCHANT_PORT/premium")
+ ok "the Paivana header carries the pay-template URI"
+ ;;
+ *)
+ fail "unexpected Paivana header: '$PAIVANA_HEADER'"
+ ;;
+esac
+
+if grep -qi "^content-security-policy:.*default-src 'none'" "$SCRATCH/page.hdr"
+then
+ ok "the paywall page carries a Content-Security-Policy"
+else
+ fail "the paywall page has no restrictive CSP"
+fi
+
+# --------------------------------------------------------------------
+# The whitelist, which only exists on this side of the paywall.
+#
+# README and paivana.conf(5) both promise that a WHITELIST expression is
+# matched against the whole path and anchored at both ends, so that
+# `/free/' waives nothing while `/free/.*' waives a subtree. The
+# regexec that decides it sits behind the paywall `-n' switches off, so
+# no other test in the tree can reach these.
+# --------------------------------------------------------------------
+
+echo "-- the whitelist --"
+
+function expect_forwarded() {
+ local path="$1" what="$2" status
+ status=$(curl -s -D "$SCRATCH/w.hdr" -o /dev/null -w "%{http_code}" "${BASE_URL}${path}")
+ if [ "302" = "$status" ]
+ then
+ fail "$what: /$path was paywalled (302)"
+ elif grep -qi '^x-upstream:' "$SCRATCH/w.hdr"
+ then
+ ok "$what"
+ else
+ fail "$what: /$path answered $status but not by the origin"
+ fi
+}
+
+function expect_paywalled() {
+ local path="$1" what="$2" status
+ status=$(curl -s -o /dev/null -w "%{http_code}" "${BASE_URL}${path}")
+ if [ "302" = "$status" ]
+ then
+ ok "$what"
+ else
+ fail "$what: /$path answered $status, want 302"
+ fi
+}
+
+expect_forwarded "echo-headers" "a whitelisted path is served without payment"
+expect_forwarded "large/64" "an alternation branch is whitelisted too"
+expect_paywalled "x/echo-headers" \
+ "a path merely containing the whitelisted one is not waived (left anchor)"
+expect_paywalled "echo-headers/x" \
+ "a path extending the whitelisted one is not waived (right anchor)"
+expect_paywalled "hello" "a path outside the whitelist is paywalled"
+
+# --------------------------------------------------------------------
+# The payment endpoint's refusals, before we have anything valid to send.
+# --------------------------------------------------------------------
+
+echo "-- the payment endpoint --"
+
+STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
+ -H "Content-Type: application/json" \
+ "${BASE_URL}.well-known/paivana" -d '{}')
+if [ "$STATUS" = "400" ]
+then
+ ok "a redemption without the required fields is refused with 400"
+else
+ fail "an empty redemption should be 400, got $STATUS"
+fi
+
+# A nonce is 16 bytes in GNUnet's Crockford base32, which is 26
+# characters; anything else must not reach the order lookup. 25 zeroes
+# is the off-by-one an implementer would write.
+STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
+ -H "Content-Type: application/json" \
+ "${BASE_URL}.well-known/paivana" \
+ -d "$(jq -n --arg w "${BASE_URL}hello" \
+ '{order_id:"no-such-order",nonce:"0000000000000000000000000",
+ expiration:{t_s:2000000000},website:$w}')")
+if [ "$STATUS" = "400" ]
+then
+ ok "a redemption with a nonce of the wrong length is refused with 400"
+else
+ fail "a short nonce should be 400, got $STATUS"
+fi
+
+# Well-formed, but naming an order the merchant has never heard of: this
+# has to reach the backend and come back as a refusal rather than as a
+# parse error, which is what tells the two apart in a log.
+read -r JUNK_NONCE _ < <(python3 "$SRCDIR/paivana_id.py" 2000000000 "${BASE_URL}hello" \
+ 000102030405060708090a0b0c0d0e0f)
+STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
+ -H "Content-Type: application/json" \
+ "${BASE_URL}.well-known/paivana" \
+ -d "$(jq -n --arg w "${BASE_URL}hello" --arg n "$JUNK_NONCE" \
+ '{order_id:"no-such-order",nonce:$n,
+ expiration:{t_s:2000000000},website:$w}')")
+if [ "$STATUS" = "404" ] || [ "$STATUS" = "409" ]
+then
+ ok "a redemption naming an order that was never paid is refused ($STATUS)"
+else
+ fail "an unknown order should be 404 or 409, got $STATUS"
+fi
+
+# --------------------------------------------------------------------
+# Buy access.
+# --------------------------------------------------------------------
+
+echo "-- paying --"
+
+WALLET="$SCRATCH/wallet.sqlite3"
+
+echo -n "Withdrawing test coins ..."
+taler-wallet-cli --no-throttle --wallet-db="$WALLET" \
+ api --expect-success 'withdrawTestBalance' \
+ "$(jq -n --arg bank "http://localhost:$BANK_PORT/" \
+ --arg exchange "http://localhost:$EXCHANGE_PORT/" \
+ '{amount:"TESTKUDOS:20",corebankApiBaseUrl:$bank,exchangeBaseUrl:$exchange}')" \
+ > "$SCRATCH/withdraw.out" 2> "$SCRATCH/withdraw.err" \
+ || exit_fail "withdrawTestBalance failed:\n$(cat "$SCRATCH/withdraw.err")"
+taler-exchange-wirewatch -a exchange-account-2 -c "$CONF" -t \
+ > "$SCRATCH/wirewatch.log" 2>&1 \
+ || exit_fail "wirewatch failed:\n$(cat "$SCRATCH/wirewatch.log")"
+timeout 240 taler-wallet-cli --no-throttle --wallet-db="$WALLET" run-until-done \
+ > "$SCRATCH/withdraw2.out" 2> "$SCRATCH/withdraw2.err" \
+ || exit_fail "the withdrawal did not finish:\n$(cat "$SCRATCH/withdraw2.err")"
+echo " OK"
+
+WEBSITE="${BASE_URL}hello"
+# The page computes this as `now + max_pickup_duration'; one hour is what
+# the template above allows.
+EXPIRATION=$(( $(date +%s) + 3600 ))
+read -r NONCE PAIVANA_ID < <(python3 "$SRCDIR/paivana_id.py" "$EXPIRATION" "$WEBSITE")
+echo " paivana_id: $PAIVANA_ID"
+
+function urlenc() {
+ python3 -c 'import sys,urllib.parse;print(urllib.parse.quote(sys.argv[1],safe=""))' "$1"
+}
+
+PAY_URI="taler+http://pay-template/localhost:$MERCHANT_PORT/premium"
+PAY_URI="$PAY_URI?session_id=$(urlenc "$PAIVANA_ID")"
+PAY_URI="$PAY_URI&fulfillment_url=$(urlenc "$WEBSITE")"
+
+echo -n "Paying ..."
+# "0" selects the first (and only) choice.
+echo "0" | timeout 240 taler-wallet-cli --no-throttle --wallet-db="$WALLET" \
+ handle-uri "$PAY_URI" -y > "$SCRATCH/pay.out" 2> "$SCRATCH/pay.err" \
+ || exit_fail "paying failed:\n$(cat "$SCRATCH/pay.err")"
+echo " OK"
+
+# This is what the paywall page polls: the merchant reports the order it
+# has seen paid under our session, which is the paivana ID.
+SESSION_URL="${MERCHANT_URL}sessions/$(urlenc "$PAIVANA_ID")"
+SESSION_URL="$SESSION_URL?fulfillment_url=$(urlenc "$WEBSITE")&timeout_ms=10000"
+STATUS=$(curl -s -o "$RESP" -w "%{http_code}" "$SESSION_URL")
+ORDER_ID=$(jq -r '.order_id // empty' < "$RESP")
+if [ "$STATUS" = "200" ] && [ -n "$ORDER_ID" ]
+then
+ ok "the merchant reports the order paid under the paivana ID we derived"
+else
+ fail "the merchant did not report a paid order: $STATUS, $(cat "$RESP")"
+ echo "=== $CHECKS checks, $FAILS failures ==="
+ exit 1
+fi
+
+# --------------------------------------------------------------------
+# Redeem it.
+# --------------------------------------------------------------------
+
+echo "-- redeeming --"
+
+function redeem() {
+ local website="$1" expiration="$2" nonce="$3" order="$4" hdr="$5"
+ curl -s -D "$hdr" -o /dev/null -w "%{http_code}" -X POST \
+ -H "Content-Type: application/json" \
+ "${BASE_URL}.well-known/paivana" \
+ -d "$(jq -n --arg o "$order" --arg n "$nonce" \
+ --argjson e "$expiration" --arg w "$website" \
+ '{order_id:$o,nonce:$n,expiration:{t_s:$e},website:$w}')"
+}
+
+STATUS=$(redeem "$WEBSITE" "$EXPIRATION" "$NONCE" "$ORDER_ID" "$SCRATCH/redeem.hdr")
+if [ "$STATUS" = "303" ]
+then
+ ok "a paid order is redeemed with 303"
+else
+ fail "redemption should be 303, got $STATUS"
+fi
+
+COOKIE=$(grep -i '^set-cookie:' "$SCRATCH/redeem.hdr" | tr -d '\r' \
+ | sed -n 's/.*\(Paivana-Cookie=[^;]*\).*/\1/p')
+if [ -n "$COOKIE" ]
+then
+ ok "the redemption sets an access cookie"
+else
+ fail "no Paivana-Cookie in the redemption response"
+fi
+
+if grep -qi '^set-cookie:.*HttpOnly' "$SCRATCH/redeem.hdr"
+then
+ ok "the access cookie is HttpOnly"
+else
+ fail "the access cookie is not HttpOnly"
+fi
+
+REDIRECT=$(grep -i '^location:' "$SCRATCH/redeem.hdr" | tr -d '\r' | cut -d' ' -f2-)
+if [ "$REDIRECT" = "$WEBSITE" ]
+then
+ ok "the redemption redirects back to the paid URL"
+else
+ fail "redemption redirects to '$REDIRECT', want '$WEBSITE'"
+fi
+
+# Nothing the client sends is taken on trust. Naming a different website
+# changes the paivana ID -- it is derived from the website -- so the
+# merchant is asked about a session it has never seen paid.
+STATUS=$(redeem "${BASE_URL}item" "$EXPIRATION" "$NONCE" "$ORDER_ID" "$SCRATCH/x.hdr")
+if [ "$STATUS" = "303" ]
+then
+ fail "the order was redeemed for a URL it did not pay for"
+else
+ ok "another website derives another paivana ID, so the order is not found ($STATUS)"
+fi
+
+# That check passes because of the session binding, and would pass even
+# if check_contract() never looked at the contract. This one reaches the
+# fulfillment-URL comparison itself: buy an order whose session IS the one
+# we will claim, but whose fulfillment URL is somewhere else entirely.
+# The merchant will sell it -- website_regex is `.*' -- and the session
+# lookup will succeed, so the only thing standing between this and a
+# cookie for a page nobody paid for is paivana comparing the contract's
+# fulfillment URL against the website being claimed.
+echo "-- an order bought for somewhere else --"
+
+TARGET="${BASE_URL}item"
+ELSEWHERE="${BASE_URL}elsewhere"
+EXP2=$(( $(date +%s) + 3600 ))
+read -r NONCE2 PAIVANA_ID2 < <(python3 "$SRCDIR/paivana_id.py" "$EXP2" "$TARGET")
+
+STATUS=$(curl -s -o "$RESP" -w "%{http_code}" \
+ -H "Content-Type: application/json" \
+ -X POST "${MERCHANT_URL}templates/premium" \
+ -d "$(jq -n --arg w "$ELSEWHERE" --arg s "$PAIVANA_ID2" \
+ '{template_type:"paivana",website:$w,paivana_id:$s}')")
+if [ "$STATUS" != "200" ]
+then
+ fail "could not create the mismatched order: $STATUS, $(cat "$RESP")"
+else
+ ORDER2=$(jq -r '.order_id' < "$RESP")
+ STATUS=$(curl -s -o "$RESP" -w "%{http_code}" -H "$AUTH" \
+ "${MERCHANT_URL}private/orders/${ORDER2}?session_id=$(urlenc "$PAIVANA_ID2")")
+ PAY2=$(jq -r '.taler_pay_uri // empty' < "$RESP")
+ if [ -z "$PAY2" ]
+ then
+ fail "no pay URI for the mismatched order: $STATUS, $(cat "$RESP")"
+ else
+ echo "0" | timeout 240 taler-wallet-cli --no-throttle --wallet-db="$WALLET" \
+ handle-uri "$PAY2" -y > "$SCRATCH/pay2.out" 2> "$SCRATCH/pay2.err" \
+ || fail "paying the mismatched order failed:\n$(cat "$SCRATCH/pay2.err")"
+
+ # Confirm the trap is armed: the merchant really does report this
+ # order as paid under the session we are about to claim.
+ STATUS=$(curl -s -o "$RESP" -w "%{http_code}" \
+ "${MERCHANT_URL}sessions/$(urlenc "$PAIVANA_ID2")?fulfillment_url=$(urlenc "$ELSEWHERE")&timeout_ms=10000")
+ if [ "$STATUS" = "200" ] && [ -n "$(jq -r '.order_id // empty' < "$RESP")" ]
+ then
+ ok "the mismatched order is paid and bound to the session we will claim"
+ else
+ fail "the mismatched order was not paid: $STATUS, $(cat "$RESP")"
+ fi
+
+ STATUS=$(redeem "$TARGET" "$EXP2" "$NONCE2" "$ORDER2" "$SCRATCH/mm.hdr")
+ if [ "$STATUS" = "303" ]
+ then
+ fail "an order bought for $ELSEWHERE minted a cookie for $TARGET"
+ else
+ ok "an order whose contract names another URL is refused ($STATUS)"
+ fi
+
+ STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$TARGET")
+ if [ "$STATUS" = "302" ]
+ then
+ ok "and that URL is still paywalled afterwards"
+ else
+ fail "$TARGET answered $STATUS after the refused redemption"
+ fi
+ fi
+fi
+
+# The expiration is hashed into the paivana ID, so asking for a longer one
+# than was paid for cannot match the session the merchant knows about.
+STATUS=$(redeem "$WEBSITE" $((EXPIRATION + 86400)) "$NONCE" "$ORDER_ID" "$SCRATCH/x.hdr")
+if [ "$STATUS" = "303" ]
+then
+ fail "the order was redeemed for a later expiration than it paid for"
+else
+ ok "redeeming for a later expiration is refused ($STATUS)"
+fi
+
+# --------------------------------------------------------------------
+# What the cookie is worth.
+# --------------------------------------------------------------------
+
+echo "-- after payment --"
+
+STATUS=$(curl -s -D "$SCRATCH/paid.hdr" -o "$SCRATCH/paid.body" \
+ -w "%{http_code}" -H "Cookie: $COOKIE" "$WEBSITE")
+if [ "$STATUS" = "200" ] && grep -qi '^x-upstream:' "$SCRATCH/paid.hdr"
+then
+ ok "the paid URL is served from the origin"
+else
+ fail "the paid URL answered $STATUS, and not from the origin"
+fi
+
+if grep -q "Hello" "$SCRATCH/paid.body"
+then
+ ok "the origin's body arrives intact"
+else
+ fail "the origin's body is not what it serves directly"
+fi
+
+STATUS=$(curl -s -o /dev/null -w "%{http_code}" -H "Cookie: $COOKIE" "${BASE_URL}item")
+if [ "$STATUS" = "302" ]
+then
+ ok "the cookie does not open a URL it was not minted for"
+else
+ fail "the cookie opened /item as well: $STATUS"
+fi
+
+STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$WEBSITE")
+if [ "$STATUS" = "302" ]
+then
+ ok "without the cookie the same URL is still paywalled"
+else
+ fail "the paid URL is open to everyone: $STATUS"
+fi
+
+# The expiration is the KDF salt, so rewriting it in the value has to
+# break the MAC rather than extend the access.
+LATER=$(( EXPIRATION + 86400 ))
+TAMPERED="Paivana-Cookie=${LATER}-${COOKIE#*-}"
+STATUS=$(curl -s -o /dev/null -w "%{http_code}" -H "Cookie: $TAMPERED" "$WEBSITE")
+if [ "$STATUS" = "302" ]
+then
+ ok "rewriting the expiration in the cookie invalidates it"
+else
+ fail "a cookie with a rewritten expiration was accepted: $STATUS"
+fi
+
+STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
+ -H "Cookie: Paivana-Cookie=not-a-cookie" "$WEBSITE")
+if [ "$STATUS" = "302" ]
+then
+ ok "a malformed cookie is refused rather than mis-parsed"
+else
+ fail "a malformed cookie answered $STATUS"
+fi
+
+# One payment can be redeemed again, from anywhere: this is deliberate
+# (design document 076, "Payment buys access, not a seat") and the check
+# is here so that a change of mind about it shows up as a test failure
+# rather than as a silent change of policy.
+STATUS=$(redeem "$WEBSITE" "$EXPIRATION" "$NONCE" "$ORDER_ID" "$SCRATCH/again.hdr")
+if [ "$STATUS" = "303" ]
+then
+ ok "the redemption is repeatable, as the design document says it is"
+else
+ fail "redeeming a second time gave $STATUS; if that is intended, 076 needs updating"
+fi
+
+echo "=== $CHECKS checks, $FAILS failure(s) ==="
+if [ "$FAILS" -gt 0 ]
+then
+ exit 1
+fi
+echo "=== all paywall tests passed ==="
+exit 0