coingate
All checks were successful
Deploy / build-deploy (push) Successful in 3m10s

This commit is contained in:
Jorijn van der Graaf 2026-08-13 23:34:19 +02:00
commit 70668af8f5
20 changed files with 2354 additions and 1048 deletions

View file

@ -60,7 +60,7 @@ trap cleanup EXIT INT TERM
# Deterministic environment: a developer shell that sourced the repo .env
# must not leak real provider keys into the test server — live Sendcloud
# rates would silently change the shipping totals asserted below.
unset MOLLIE_API_KEY BUNQ_API_KEY SENDCLOUD_PUBLIC_KEY SENDCLOUD_SECRET_KEY SENDCLOUD_METHOD 2>/dev/null || true
unset MOLLIE_API_KEY COINGATE_API_KEY SENDCLOUD_PUBLIC_KEY SENDCLOUD_SECRET_KEY SENDCLOUD_METHOD 2>/dev/null || true
# An ephemeral GPG key so invoice signing runs the REAL signing path and the
# suite can verify the signature. gpg is required (CI installs gnupg with the
@ -86,7 +86,35 @@ chmod +x "$WORK/sendmail"
export MAIL_COMMAND="$WORK/sendmail"
export MAIL_FROM='Catcrafts <info@catcrafts.net>'
"$SERVER" --serve "$PORT" --orders="$ORDERS" --rail=fake >"$WORK/server.log" 2>&1 &
# The shipping rate table. Shipping has no compiled-in fallback any more — the
# carrier table is the only source of prices — so without this file every
# checkout correctly refuses and the whole order suite would be testing the
# refusal path by accident.
#
# This is byte-for-byte the cache the daily Sendcloud refresh writes, so the
# suite drives the production lookup with no test-only hook that could drift
# from it: country -> [[maxWeightGrams, consumerCents], ...], prices already
# VAT-inclusive (the gross-up happens at fetch, not at load).
#
# The single-unit rates are the €15 / €25 / €55 the totals below assert. The
# second band exists so the too-heavy refusal has a real ceiling to hit:
# 10 kg / 700 g per unit = 14 units per parcel.
cat >"$ORDERS.shipping.json" <<'JSON'
{"method":"e2e fixture","fetched_at":"2026-01-01T00:00:00Z","per_country":{
"NL":[[2000,1500],[10000,2900]],
"DE":[[2000,2500],[10000,4200]],
"GB":[[2000,5500],[10000,7900]]}}
JSON
# BOTH slots on the fake rail, so the suite covers the payment CHOICE as well
# as the lifecycle: that the form offers it, that the picked rail is what gets
# written to the ledger, and that an order polls the provider that issued its
# link. Which rail is behind each slot is exactly the part these tests should
# not care about — that is what makes the same assertions valid for Mollie and
# CoinGate. Both fakes share one marker file, so touching it below settles
# whichever orders are outstanding.
"$SERVER" --serve "$PORT" --orders="$ORDERS" --rail=fake --crypto-rail=fake-crypto \
>"$WORK/server.log" 2>&1 &
SRV_PID=$!
# Wait for the listener rather than sleeping a fixed amount: a fixed sleep is
@ -256,16 +284,32 @@ else
fi
body_has /shop/fp6-pmos '"price":"563.30"' "schema price is the checkout integer"
# Merchant-grade offer fields: what Merchant Center's website-crawl feed
# reads. Shipping uses the static zone rates (listing may overstate, never
# understate what checkout charges); returns mirror the terms page.
# reads. Shipping is published from the live carrier table at one unit's
# weight — the same integers checkout charges, so the listing cannot quote a
# rate the till won't honour; returns mirror the terms page.
body_has /shop/fp6-pmos 'OfferShippingDetails' "offers carry shipping details"
body_has /shop/fp6-pmos 'MerchantReturnPolicy' "offers carry a return policy"
body_has /shop/fp6-pmos '"sku":"fp6-pmos-green"' "offers carry per-variant skus"
body_has /shop/fp6-pmos '"brand":{"@type":"Brand","name":"Fairphone"}' "product carries the hardware brand"
# One entry per (transit tier, price) the carrier table produces — three, for
# the fixture's NL/DE/GB. Not a fixed property of the code any more: it is
# whatever the carrier prices, which is the point.
if extract_ld /shop/fp6-pmos | jq -e '.hasVariant[0].offers.shippingDetails | length == 3' >/dev/null 2>&1; then
ok "shipping details cover all three zones"
ok "shipping details group the carrier's rates"
else
bad "shipping zones" "expected NL + EU + world tiers in the first variant's offer"
bad "shipping groups" "expected 3 rate groups from the fixture table in the first variant's offer"
fi
# The advertised rate IS the carrier's single-unit price, and a destination the
# table does not cover is never advertised.
if extract_ld /shop/fp6-pmos | jq -e '[.hasVariant[0].offers.shippingDetails[].shippingRate.value] | sort == ["15.00","25.00","55.00"]' >/dev/null 2>&1; then
ok "published shipping rates come from the carrier table"
else
bad "shipping rates" "expected the fixture's 15.00/25.00/55.00 in the offer"
fi
if extract_ld /shop/fp6-pmos | jq -e '[.hasVariant[0].offers.shippingDetails[].shippingDestination.addressCountry[]] | index("AU") == null' >/dev/null 2>&1; then
ok "an uncovered destination is not advertised"
else
bad "shipping coverage" "AU has no carrier rate but appears in the listing"
fi
if [ "$SHOP_OPEN" = 1 ]; then
body_has /shop/fp6-pmos 'schema.org/InStock' "open shop maps to InStock availability"
@ -289,15 +333,19 @@ fi
# The shop card: one euro number as the crawler/no-JS text, every supported
# currency pre-formatted server-side as a data attribute for the script to
# pick from. Converted amounts carry "~". CAD converts the ex-VAT price;
# SEK (an EU member's currency) converts the VAT-inclusive price.
# pick from. Converted amounts carry "~". GBP converts the ex-VAT price;
# SEK (an EU member's currency) converts the VAT-inclusive price. USD and CAD
# are absent on purpose — the shop refuses those destinations, so it does not
# quote a local price to them either.
body_has /shop 'class="price__single"' "shop card renders the single-number price"
body_has /shop 'data-cad="~CA$' "shop card carries a CAD conversion"
body_has /shop 'data-gbp="~£' "shop card carries a GBP conversion"
body_has /shop 'data-sek="~kr ' "shop card carries an SEK conversion"
body_has /shop 'data-world="€465.54"' "shop card carries the euro export fallback"
# The product page gets the same headline element, so a Canadian sees ~CA$
body_lacks /shop 'data-usd=' "no USD price for a country the shop refuses"
body_lacks /shop 'data-cad=' "no CAD price for a country the shop refuses"
# The product page gets the same headline element, so a British visitor sees ~£
# at the top there too, and the buy card states the customs position plainly.
body_has /shop/fp6-pmos 'data-cad="~CA$' "product page headline carries the conversion"
body_has /shop/fp6-pmos 'data-gbp="~£' "product page headline carries the conversion"
body_has /shop/fp6-pmos 'indicative only' "buy card says converted prices are indicative"
body_has /shop/fp6-pmos 'customs authority' "buy card names whose problem import charges are"
body_lacks /shop/fp6-pmos 'collected on arrival' "the vague customs phrasing is gone"
@ -680,6 +728,13 @@ if grep -q '"country":"NL"' "$ORDERS" && grep -q '"total_minor":57830' "$ORDERS"
else
bad "order storage" "expected NL total_minor 57830 in $ORDERS"
fi
# No `pay` field in that submission, which is what a form with only one rail
# configured posts: it must land on the bank rail rather than nothing.
if grep -q '"pay_choice":"bank"' "$ORDERS"; then
ok "a submission with no payment choice records bank"
else
bad "default payment choice" "expected pay_choice bank in $ORDERS"
fi
# The order page: awaiting payment, pay link, reference, self-refreshing,
# never indexed, never cached.
@ -700,43 +755,100 @@ status /order/00000000000000000000000000000000 404
status /order/not-a-token 404
status /order/deadbeef 404
# ── the payment choice ────────────────────────────────────────────────
#
# Both slots are configured here, so the form must offer both and the picked
# one must survive all the way into the ledger. The ledger is the assertion
# that matters: it is what the reconciler later reads to decide WHICH provider
# may confirm the order, so a choice that renders but is not stored would mean
# crypto orders being asked about at Mollie.
body_has /shop/fp6-pmos 'name="pay"' "the form offers a payment choice"
body_has /shop/fp6-pmos 'value="crypto"' "crypto is one of the choices"
body_has /shop/fp6-pmos 'value="bank" checked' "bank is the pre-selected choice"
LOC_C=$(curl -s -o /dev/null -w '%{redirect_url}' -X POST \
-d "$GOOD&pay=crypto" "$BASE/shop/fp6-pmos")
TOKEN_C=$(printf '%s' "$LOC_C" | grep -oE '/order/[0-9a-f]{32}$' | cut -d/ -f3 || true)
if [ -n "$TOKEN_C" ]; then
ok "a crypto order goes through"
if grep -q "\"id\":\"$TOKEN_C\".*\"pay_choice\":\"crypto\"" "$ORDERS"; then
ok "the crypto choice is what the ledger records"
else
bad "crypto choice storage" "no pay_choice crypto for $TOKEN_C in $ORDERS"
fi
# The order page has to promise what is actually behind the button — the
# bank copy on a crypto order would send someone looking for iDEAL.
CRYPTO_HTML=$(curl -s "$BASE/order/$TOKEN_C")
if printf '%s' "$CRYPTO_HTML" | grep -qF 'Lightning'; then
ok "the crypto order page describes the crypto invoice"
else
bad "crypto order copy" "no crypto wording on /order/$TOKEN_C"
fi
if printf '%s' "$CRYPTO_HTML" | grep -qF 'iDEAL'; then
bad "crypto order copy" "the crypto order page still offers iDEAL"
else
ok "the crypto order page does not promise iDEAL"
fi
else
bad "crypto checkout" "no token from Location: $LOC_C"
fi
# A payment method nobody offers is refused, and refused as a FIELD error so
# the form comes back with the choice highlighted rather than a bare 400.
BOGUS_CODE=$(curl -s -o "$WORK/bogus-pay.html" -w '%{http_code}' -X POST \
-d "$GOOD&pay=invoice-me-later" "$BASE/shop/fp6-pmos")
if [ "$BOGUS_CODE" = "422" ]; then
ok "an unknown payment method is refused"
else
bad "unknown payment method" "expected 422, got $BOGUS_CODE"
fi
if grep -qF 'Pick one of the payment methods' "$WORK/bogus-pay.html"; then
ok "the refusal names the payment field"
else
bad "unknown payment method" "no payment field error in the response"
fi
# A non-EU order: ex-VAT goods, world shipping, and the indicative national
# currency line sourced from the build-time ECB rates.
LOC_CA=$(curl -s -o /dev/null -w '%{redirect_url}' -X POST -d 'email=ca%40example.org&name=Terry&street=1%20Bloor%20St&postal=M4W&city=Toronto&country=CA' "$BASE/shop/fp6-pmos")
TOKEN_CA=$(printf '%s' "$LOC_CA" | grep -oE '/order/[0-9a-f]{32}$' | cut -d/ -f3 || true)
if [ -n "$TOKEN_CA" ]; then
CA_HTML=$(curl -s "$BASE/order/$TOKEN_CA")
# currency line sourced from the build-time ECB rates. GB rather than a North
# American destination because those are refused outright (see the block below).
LOC_GB=$(curl -s -o /dev/null -w '%{redirect_url}' -X POST -d 'email=gb%40example.org&name=Terry&street=1%20Baker%20St&postal=W1U&city=London&country=GB' "$BASE/shop/fp6-pmos")
TOKEN_GB=$(printf '%s' "$LOC_GB" | grep -oE '/order/[0-9a-f]{32}$' | cut -d/ -f3 || true)
if [ -n "$TOKEN_GB" ]; then
GB_HTML=$(curl -s "$BASE/order/$TOKEN_GB")
# €465.54 goods (green net) + €55 world shipping = €520.54
if printf '%s' "$CA_HTML" | grep -qF '€520.54'; then
if printf '%s' "$GB_HTML" | grep -qF '€520.54'; then
ok "export order total is ex-VAT + world shipping"
else
bad "export order total" "€520.54 not on the page"
fi
if printf '%s' "$CA_HTML" | grep -qF 'Zero-rated export'; then
if printf '%s' "$GB_HTML" | grep -qF 'Zero-rated export'; then
ok "export order states the VAT treatment"
else
bad "export VAT copy" "missing zero-rated export note"
fi
if printf '%s' "$CA_HTML" | grep -qE '≈ CA\$[0-9]+'; then
ok "export order shows the indicative CAD amount"
if printf '%s' "$GB_HTML" | grep -qE '≈ £[0-9]+'; then
ok "export order shows the indicative GBP amount"
else
# Rates are optional by design; their absence must not fail the file
# check, but in this repo rates.json is committed so it must appear.
bad "indicative currency" "no ≈ CA\$ line on the CA order page"
bad "indicative currency" "no ≈ £ line on the GB order page"
fi
if printf '%s' "$CA_HTML" | grep -qF 'indicative'; then
if printf '%s' "$GB_HTML" | grep -qF 'indicative'; then
ok "conversion is labelled indicative"
else
bad "indicative label" "the conversion is not labelled indicative"
fi
else
bad "CA checkout" "no token from Location: $LOC_CA"
bad "GB checkout" "no token from Location: $LOC_GB"
fi
# Destinations the shop refuses. A well-formed, real address in either country
# must not produce an order, a payment link or a redirect — the 422 is the whole
# point, and the page has to say why rather than failing silently.
# A two-unit white export order: unit €665, line €1330, net from the LINE
# total (not per unit) = €1082.45, plus €55 world shipping = €1137.45.
LOC_W=$(curl -s -o /dev/null -w '%{redirect_url}' -X POST \
-d 'email=w%40example.org&name=W&street=X%201&postal=1&city=Y&country=CA&color=white&quantity=2' \
-d 'email=w%40example.org&name=W&street=X%201&postal=1&city=Y&country=GB&color=white&quantity=2' \
"$BASE/shop/fp6-pmos")
TOKEN_W=$(printf '%s' "$LOC_W" | grep -oE '/order/[0-9a-f]{32}$' | cut -d/ -f3 || true)
if [ -n "$TOKEN_W" ]; then
@ -774,7 +886,56 @@ else
bad "bulk order" "quantity=9 did not create an order: $LOC_9"
fi
body_has /shop/fp6-pmos 'type="number"' "quantity is a number input, not a dropdown"
body_has /shop/fp6-pmos 'max="99"' "quantity input carries the technical ceiling"
# The ceiling is physical now: the heaviest band any destination offers
# (10 kg in the fixture) divided by the boxed unit weight (700 g) = 14. The
# input advertises the BEST case across destinations; the per-country limit is
# enforced on submit, below.
body_has /shop/fp6-pmos 'max="14"' "quantity input carries the one-parcel ceiling"
# One order is one parcel. Fifteen units is 10.5 kg, past every band the
# fixture has, so it must be refused rather than quoted a rate the carrier
# would not accept — and the refusal has to say what WOULD fit, or the buyer is
# left guessing.
HEAVY_HTML=$(curl -s -X POST -d "$GOOD&quantity=15" "$BASE/shop/fp6-pmos")
status /shop/fp6-pmos 422 POST "$GOOD&quantity=15"
if printf '%s' "$HEAVY_HTML" | grep -qF 'up to 14 per order'; then
ok "an over-weight order is refused with the quantity that fits"
else
bad "too-heavy refusal" "no 'up to 14 per order' in the response"
fi
if printf '%s' "$HEAVY_HTML" | grep -qF 'orders@catcrafts.net'; then
ok "the too-heavy refusal offers a way to order anyway"
else
bad "too-heavy refusal" "no contact address in the response"
fi
# A destination the carrier has no rate for. Since the zone fallback went away
# there is no price to invent, so this is a refusal — and specifically NOT the
# no-sale refusal, which is a different (policy) reason with different wording.
AU_ADDR='email=au%40example.org&name=Alex&street=1%20George%20St&postal=2000&city=Sydney&country=AU'
AU_HTML=$(curl -s -X POST -d "$AU_ADDR" "$BASE/shop/fp6-pmos")
status /shop/fp6-pmos 422 POST "$AU_ADDR"
if printf '%s' "$AU_HTML" | grep -qF 'No carrier rate for AU'; then
ok "an uncovered destination is refused, naming the country"
else
bad "uncovered destination" "no carrier-rate refusal for AU in the response"
fi
# ...and specifically the country FIELD ERROR must be the carrier message, not
# the no-sale one. Matched on the error markup rather than the bare sentence:
# the no-sale line is standing copy above every buy form, so its mere presence
# on the page says nothing about why this order was refused.
if printf '%s' "$AU_HTML" | grep -qF 'field__error">Catcrafts does not sell'; then
bad "uncovered destination" "AU was refused as a policy no-sale, not a missing rate"
else
ok "an uncovered destination is not confused with a refused one"
fi
AU_ORDERS=$(wc -l < "$ORDERS" 2>/dev/null || echo 0)
curl -s -o /dev/null -X POST -d "$AU_ADDR" "$BASE/shop/fp6-pmos"
if [ "$(wc -l < "$ORDERS" 2>/dev/null || echo 0)" = "$AU_ORDERS" ]; then
ok "a refused destination writes no order"
else
bad "uncovered destination" "an order was stored for a country with no rate"
fi
# No invoice exists before the money does — awaiting orders answer 404.
status "/order/$TOKEN/invoice.md" 404
@ -880,19 +1041,23 @@ fi
echo "== the confirmation email =="
# Every paid order gets exactly one confirmation with the signed invoice
# attached. Four orders were paid above; the mailer sweeps the ledger every
# 2 s, so all four messages should exist within a few sweeps.
# attached. The expected count comes from the LEDGER rather than a number
# written here: "one per paid order" is the actual property, and a literal
# would have to be edited by anyone who adds an order above — which is a test
# that fails for the wrong reason and gets bumped without being read. The
# mailer sweeps every 2 s, so the messages land within a few sweeps.
n_paid=$(grep -c '"status":"paid"' "$ORDERS" || true)
i=0
until [ "$(ls "$WORK"/mail-*.eml 2>/dev/null | wc -l)" -ge 4 ]; do
until [ "$(ls "$WORK"/mail-*.eml 2>/dev/null | wc -l)" -ge "$n_paid" ]; do
i=$((i + 1))
if [ "$i" -gt 60 ]; then break; fi
sleep 0.25
done
n_mail=$(ls "$WORK"/mail-*.eml 2>/dev/null | wc -l)
if [ "$n_mail" -eq 4 ]; then
if [ "$n_mail" -eq "$n_paid" ]; then
ok "one confirmation email per paid order ($n_mail sent)"
else
bad "confirmation email count" "expected 4, got $n_mail"
bad "confirmation email count" "expected $n_paid, got $n_mail"
fi
# The NL order's message, found by its own order link (the same email address
@ -921,11 +1086,11 @@ else
fi
# The export order's message states the VAT treatment its invoice carries.
MAIL_CA=$(grep -l "/order/$TOKEN_CA" "$WORK"/mail-*.eml 2>/dev/null | head -n1)
if [ -n "$MAIL_CA" ] && grep -qF 'zero-rated export' "$MAIL_CA"; then
MAIL_GB=$(grep -l "/order/$TOKEN_GB" "$WORK"/mail-*.eml 2>/dev/null | head -n1)
if [ -n "$MAIL_GB" ] && grep -qF 'zero-rated export' "$MAIL_GB"; then
ok "export confirmation states the zero-rated treatment"
else
bad "export confirmation" "no message for the CA order, or no VAT note in it"
bad "export confirmation" "no message for the GB order, or no VAT note in it"
fi
# Idempotency comes from the ledger's notified event, not from luck in
@ -962,6 +1127,25 @@ status /shop/fp6-pmos 422 POST 'name=Ada&street=x&postal=1&city=y&country=NL'
status /shop/fp6-pmos 422 POST 'email=nonsense&'"$GOOD" # bad email (dup field keeps first)
status /shop/fp6-pmos 422 POST 'email=a%40b.example&country=NL' # missing address
status /shop/fp6-pmos 422 POST "$GOOD&website=spam" # honeypot
# Destinations the shop refuses (Money::NoSaleCountries). Well-formed, real
# addresses: the refusal is policy, not a shape check, so it has to hold for
# every spelling the form accepts. Deliberately outside the SHOP_OPEN gate —
# validation runs before the coming-soon check, so this must answer 422 whether
# the shop is open or not, and it is the assertion that would catch the block
# being lost in a refactor.
US_ADDR='email=us%40example.org&name=Pat&street=1%20Main%20St&postal=43004&city=Columbus&country=US'
ORDERS_BEFORE=$(wc -l < "$ORDERS" 2>/dev/null || echo 0)
status /shop/fp6-pmos 422 POST "$US_ADDR" # United States
status /shop/fp6-pmos 422 POST 'email=ca%40example.org&name=Terry&street=1%20Bloor%20St&postal=M4W&city=Toronto&country=CA'
status /shop/fp6-pmos 422 POST 'email=us%40example.org&name=Pat&street=1%20Main%20St&postal=43004&city=Columbus&country=us'
# Refused in validation means nothing reached the ledger and no payment link
# was ever created.
ORDERS_AFTER=$(wc -l < "$ORDERS" 2>/dev/null || echo 0)
if [ "$ORDERS_BEFORE" = "$ORDERS_AFTER" ]; then
ok "a refused destination creates no order record"
else
bad "refusal leaks an order" "orders went from $ORDERS_BEFORE to $ORDERS_AFTER"
fi
status /shop/nope 404 POST "$GOOD" # unknown product
status /projects 405 POST 'x=1' # not a form target
@ -984,6 +1168,26 @@ if grep -qF 'field__error' "$WORK/rejected.html"; then
else
bad "rejected form error" "no .field__error in the response"
fi
# A refused destination says why, in the form, with the address still in it —
# the visitor should learn the shop does not sell there, not that something
# went wrong.
curl -s -X POST -d "$US_ADDR" "$BASE/shop/fp6-pmos" > "$WORK/refused.html"
if grep -qF 'does not sell or ship to the United States or Canada' "$WORK/refused.html"; then
ok "refusal explains itself on the form"
else
bad "refusal copy" "the US/CA refusal message is not on the re-rendered page"
fi
if grep -qF 'value="Pat"' "$WORK/refused.html"; then
ok "a refused submission keeps what was typed"
else
bad "refusal echo" "form values were dropped on refusal"
fi
# The buy panel warns before anyone fills it in, and the preview script carries
# the same list so it cannot quote a total the server would refuse.
body_has /shop/fp6-pmos 'does not sell or ship to the United States or Canada' \
"buy panel states where the shop does not sell"
body_has /shop/fp6-pmos '&quot;x&quot;:[&quot;US&quot;,&quot;CA&quot;]' \
"total preview knows the refused destinations"
# The honeypot message must not name the trap, or it teaches the next bot.
# Only the ERROR NOTICE is inspected: the re-rendered form legitimately
# contains the name="website" field itself — that IS the trap, re-armed.