paivana

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

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:
M.gitignore | 1+
Msrc/tests/README | 92++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Msrc/tests/meson.build | 17+++++++++++++++++
Asrc/tests/paivana_id.py | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/tests/setup.sh | 90+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/tests/test_paywall.conf | 193+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/tests/test_paywall.sh | 761+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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