All checks were successful
Deploy / build-deploy (push) Successful in 2m20s
1377 lines
68 KiB
Shell
Executable file
1377 lines
68 KiB
Shell
Executable file
#!/bin/sh
|
||
# End-to-end HTTP tests against a running catcrafts-server.
|
||
#
|
||
# Why this exists separately from --selftest: that one covers the pure
|
||
# functions (escaping, JSON, form validation) in-process. This covers the
|
||
# things only a real request can show — status codes, headers, redirects,
|
||
# form submission, and whether a page is actually complete without
|
||
# JavaScript. Those are exactly the properties that matter at launch and the
|
||
# ones a unit test cannot observe.
|
||
#
|
||
# Runs the server itself on a scratch port with a temporary orders file and the
|
||
# FAKE payment rail, so it never touches real data, never dials Mollie, and needs
|
||
# no setup. The fake rail makes the whole order lifecycle testable: it hands
|
||
# out pretend payment links, and reports "paid" once the marker file exists —
|
||
# which is how these tests simulate the customer paying.
|
||
#
|
||
# usage: tools/e2e.sh [path-to-catcrafts-server]
|
||
#
|
||
# Exits non-zero on the first failure, so it works as a CI gate.
|
||
|
||
set -eu
|
||
|
||
SERVER="${1:-}"
|
||
if [ -z "$SERVER" ]; then
|
||
SERVER=$(find bin -maxdepth 1 -type d -name 'Catcrafts.Server-*' | sort | head -n1)/catcrafts-server
|
||
fi
|
||
[ -x "$SERVER" ] || { echo "e2e: server binary not found or not executable: $SERVER" >&2; exit 1; }
|
||
|
||
PORT="${E2E_PORT:-8199}"
|
||
BASE="http://127.0.0.1:$PORT"
|
||
|
||
# Where tools/fetch-media.sh put the mirrored files.
|
||
#
|
||
# NOT hardcoded to ./media: in the repo that is where they land, but CI points
|
||
# the mirror at the persistent mount instead (/deploy-app/media) so the copies
|
||
# survive a deploy. A check that assumed the dev layout reported every single
|
||
# referenced file as missing on CI while the files were perfectly fine on the
|
||
# mount — a failure that said "40 files missing" and meant "wrong directory".
|
||
# Override with E2E_MEDIA_DIR; the workflow passes the same path it gave the
|
||
# mirror.
|
||
MEDIA_DIR="${E2E_MEDIA_DIR:-}"
|
||
if [ -z "$MEDIA_DIR" ]; then
|
||
for d in media /deploy-app/media; do
|
||
if [ -d "$d" ]; then MEDIA_DIR="$d"; break; fi
|
||
done
|
||
fi
|
||
WORK="$(mktemp -d)"
|
||
ORDERS="$WORK/orders.jsonl"
|
||
|
||
pass=0
|
||
fail=0
|
||
skipped=0
|
||
|
||
cleanup() {
|
||
[ -n "${SRV_PID:-}" ] && kill "$SRV_PID" 2>/dev/null || true
|
||
rm -rf "$WORK"
|
||
}
|
||
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 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
|
||
# base tools); a missing binary should fail loudly, not skip silently.
|
||
export GNUPGHOME="$WORK/gnupg"
|
||
mkdir -p "$GNUPGHOME"; chmod 700 "$GNUPGHOME"
|
||
gpg --batch --passphrase '' --quick-gen-key 'Catcrafts e2e <invoices@e2e.invalid>' \
|
||
default default never >/dev/null 2>&1 \
|
||
|| { echo "e2e: could not create a GPG key (is gnupg installed?)" >&2; exit 1; }
|
||
export INVOICE_GPG_KEY='invoices@e2e.invalid'
|
||
|
||
# A fake sendmail, so the mailer's REAL path — build the MIME message, attach
|
||
# the signed invoice, shell out — runs with zero network. Each accepted
|
||
# message lands as its own mail-<n>.eml; the mailer sends sequentially from
|
||
# one thread, so the count-up cannot race itself.
|
||
cat > "$WORK/sendmail" <<EOF
|
||
#!/bin/sh
|
||
n=1
|
||
while [ -e "$WORK/mail-\$n.eml" ]; do n=\$((n + 1)); done
|
||
cat > "$WORK/mail-\$n.eml"
|
||
EOF
|
||
chmod +x "$WORK/sendmail"
|
||
export MAIL_COMMAND="$WORK/sendmail"
|
||
export MAIL_FROM='Catcrafts <info@catcrafts.net>'
|
||
|
||
# The bunq mutation callback. The secret IS the last segment of the callback
|
||
# URL, and setting it is what brings the endpoint into existence — unset, the
|
||
# path is an ordinary 404. Note what is NOT here: a bunq API key. One could
|
||
# initiate payments, so no such key ever reaches the server; it only receives.
|
||
export BUNQ_CALLBACK_SECRET='e2e-callback-secret-not-a-real-one'
|
||
|
||
# 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
|
||
# either too short on a loaded machine or wasted time on a fast one.
|
||
i=0
|
||
while [ "$i" -lt 100 ]; do
|
||
if curl -s -o /dev/null "$BASE/api/healthz" 2>/dev/null; then break; fi
|
||
i=$((i + 1))
|
||
sleep 0.1
|
||
done
|
||
if [ "$i" -ge 100 ]; then
|
||
echo "e2e: server did not come up on $PORT" >&2
|
||
cat "$WORK/server.log" >&2
|
||
exit 1
|
||
fi
|
||
|
||
ok() { pass=$((pass + 1)); printf ' ok %s\n' "$1"; }
|
||
bad() { fail=$((fail + 1)); printf ' FAIL %s\n %s\n' "$1" "$2"; }
|
||
# Counted and reported separately, never as a pass: a check that silently did not
|
||
# run is how a suite ends up reporting green over an untested code path.
|
||
skip() { skipped=$((skipped + 1)); printf ' SKIP %s\n %s\n' "$1" "$2"; }
|
||
|
||
# status <path> <expected> [method] [data]
|
||
status() {
|
||
_p="$1"; _want="$2"; _m="${3:-GET}"; _d="${4:-}"
|
||
if [ "$_m" = POST ]; then
|
||
_got=$(curl -s -o /dev/null -w '%{http_code}' -X POST -d "$_d" "$BASE$_p")
|
||
elif [ "$_m" = HEAD ]; then
|
||
# --head, not -X HEAD: with -X curl still waits for a response body
|
||
# that a correct HEAD reply never sends, and hangs until timeout.
|
||
_got=$(curl -s -o /dev/null -w '%{http_code}' --head "$BASE$_p")
|
||
else
|
||
_got=$(curl -s -o /dev/null -w '%{http_code}' -X "$_m" "$BASE$_p")
|
||
fi
|
||
[ "$_got" = "$_want" ] && ok "$_m $_p -> $_want" \
|
||
|| bad "$_m $_p" "expected $_want, got $_got"
|
||
}
|
||
|
||
# body_has <path> <string> <label>
|
||
body_has() {
|
||
if curl -s "$BASE$1" | grep -qF -- "$2"; then ok "$3"; else bad "$3" "missing: $2"; fi
|
||
}
|
||
|
||
# body_lacks <path> <string> <label>
|
||
body_lacks() {
|
||
if curl -s "$BASE$1" | grep -qF -- "$2"; then bad "$3" "unexpectedly present: $2"; else ok "$3"; fi
|
||
}
|
||
|
||
# header_has <path> <regex> <label>
|
||
header_has() {
|
||
if curl -sD- -o /dev/null "$BASE$1" | grep -qiE -- "$2"; then ok "$3"; else bad "$3" "no header matching: $2"; fi
|
||
}
|
||
|
||
echo "== status codes =="
|
||
for p in / /about /shop /shop/fp6-pmos /projects /posts /demos /demos/raytracer \
|
||
/financials \
|
||
/legal/privacy /legal/terms /legal/imprint /feed.xml /sitemap.xml /api/healthz; do
|
||
status "$p" 200
|
||
done
|
||
# Trailing slashes must normalise, not 404 or duplicate the canonical URL.
|
||
status /projects/ 200
|
||
status /shop/ 200
|
||
# A real 404, which a client-side router cannot produce — this is the whole
|
||
# reason the backend exists.
|
||
status /nope 404
|
||
status /shop/nope 404
|
||
status /legal/nope 404
|
||
# A slug that cannot be one of ours is rejected before any lookup.
|
||
status /shop/BAD--slug 404
|
||
status /demos/nope 404
|
||
# A post slug that parsed but names nothing must be a real 404, or every typo
|
||
# and every retired post becomes an indexable empty page.
|
||
status /posts/nope 404
|
||
status /posts/BAD--slug 404
|
||
# The retired blog URLs are still in the wild; they must redirect, not 404.
|
||
status /blog 301
|
||
status /blog/hello-world 301
|
||
# /demo was the single-demo URL before there was a list; it must redirect, not
|
||
# 404, because it was linked from the home page.
|
||
status /demo 301
|
||
|
||
echo "== redirects =="
|
||
if curl -sD- -o /dev/null "$BASE/blog/hello-world" | grep -qi '^location: */posts'; then
|
||
ok "/blog/* sends Location: /posts"
|
||
else
|
||
bad "/blog/* Location header" "not /posts"
|
||
fi
|
||
if curl -sD- -o /dev/null "$BASE/demo" | grep -qi '^location: */demos'; then
|
||
ok "/demo sends Location: /demos"
|
||
else
|
||
bad "/demo Location header" "not /demos"
|
||
fi
|
||
|
||
# Open shop or coming-soon? The pricing blob (data-cc) exists only on the real
|
||
# order form, so its presence is the probe. Used by the script-shape checks
|
||
# below and by the checkout/order/invoice gating further down.
|
||
if curl -s "$BASE/shop/fp6-pmos" | grep -q 'data-cc='; then SHOP_OPEN=1; else SHOP_OPEN=0; fi
|
||
|
||
echo "== the no-JavaScript guarantee =="
|
||
# The site must be complete without the wasm module. If these fail, the SSR
|
||
# work has regressed and crawlers see an empty page again.
|
||
body_has /projects "imsd" "/projects has content in the HTML"
|
||
body_has /projects "<title>Projects" "/projects has a real title"
|
||
body_lacks /projects "<script" "/projects ships no script at all"
|
||
body_lacks /legal/privacy "<script" "/legal/privacy ships no script"
|
||
body_has /financials "<title>Financials" "/financials has a real title"
|
||
body_lacks /financials "<script" "/financials ships no script"
|
||
|
||
# Placeholders are dev-only markers; one reaching production is a content bug
|
||
# (an imprint that says PLACEHOLDER once shipped exactly that way).
|
||
for pg in /legal/privacy /legal/terms /legal/imprint /shop/fp6-pmos /financials; do
|
||
body_lacks "$pg" 'PLACEHOLDER' "$pg ships no placeholder markers"
|
||
done
|
||
|
||
# Shop pages are the one exception to script-free: they carry exactly ONE
|
||
# EXECUTABLE inline script — the timezone price hint, whose tag is the bare
|
||
# <script>. JSON-LD blocks (<script type="application/ld+json">) are inert
|
||
# data the browser never executes, so they don't count against the rule.
|
||
# Pin the shape hard: inline only (no src=, so nothing external can ever ride
|
||
# in under this exception), no network APIs, and the page must remain
|
||
# complete without it — both prices in the markup regardless.
|
||
for pg in /shop /shop/fp6-pmos; do
|
||
n=$(curl -s "$BASE$pg" | grep -c '<script>' || true)
|
||
if [ "$n" = 1 ]; then
|
||
ok "$pg carries exactly one executable script (the price hint)"
|
||
else
|
||
bad "$pg script count" "expected 1 bare <script>, got $n"
|
||
fi
|
||
if curl -s "$BASE$pg" | grep -qE '<script[^>]*src='; then
|
||
bad "$pg script" "an external script crept in under the inline exception"
|
||
else
|
||
ok "$pg script is inline, not external"
|
||
fi
|
||
if curl -s "$BASE$pg" | grep -oE '<script>.*</script>' | grep -qE 'fetch|XMLHttpRequest|WebSocket|navigator\.sendBeacon'; then
|
||
bad "$pg script" "the price hint makes network calls"
|
||
else
|
||
ok "$pg script makes no network calls"
|
||
fi
|
||
done
|
||
body_has /shop/fp6-pmos 'cc-noneu' "price hint tags the non-EU outcome"
|
||
body_has /shop/fp6-pmos 'cc-eu' "price hint tags the confirmed-EU outcome too"
|
||
|
||
# ── structured data ──
|
||
# The JSON-LD blocks are the machine-readable identity and offer records,
|
||
# born from a Google AI overview flatly asserting that nobody named Catcrafts
|
||
# sells Fairphone hardware. Both must parse as JSON and carry the facts.
|
||
extract_ld() {
|
||
curl -s "$BASE$1" \
|
||
| grep -o '<script type="application/ld+json">[^<]*' \
|
||
| sed 's/^<script type="application\/ld+json">//'
|
||
}
|
||
# The home record is an @graph (Organization + WebSite joined by @id); the
|
||
# org node inside it must carry the registered identity.
|
||
if extract_ld / | jq -e '[."@graph"[]? | select(."@type" == "Organization" and .vatID == "NL003329281B38")] | length == 1' >/dev/null 2>&1; then
|
||
ok "home Organization schema parses and carries the VAT identity"
|
||
else
|
||
bad "Organization schema" "missing, unparseable, or wrong identity"
|
||
fi
|
||
# Variants are a ProductGroup: one variant Product per colour, each with its
|
||
# own single offer — not one Product with three prices.
|
||
if extract_ld /shop/fp6-pmos | jq -e '."@type" == "ProductGroup" and (.hasVariant | length) == 3 and ([.hasVariant[].offers] | length) == 3' >/dev/null 2>&1; then
|
||
ok "product schema parses with one variant (and offer) per colour"
|
||
else
|
||
bad "ProductGroup schema" "missing, unparseable, or wrong variant count"
|
||
fi
|
||
if extract_ld /shop | jq -e '."@type" == "ItemList" and (.itemListElement | length) >= 1' >/dev/null 2>&1; then
|
||
ok "shop index carries an ItemList of the product pages"
|
||
else
|
||
bad "shop ItemList" "missing or unparseable"
|
||
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 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 group the carrier's rates"
|
||
else
|
||
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"
|
||
else
|
||
body_has /shop/fp6-pmos 'schema.org/PreOrder' "coming-soon maps to PreOrder availability"
|
||
fi
|
||
# og: tags are the link-preview card on Mastodon and Lemmy — where the
|
||
# traffic actually comes from.
|
||
body_has / 'property="og:title"' "home page has og:title"
|
||
body_has /shop/fp6-pmos 'og:image" content="https://catcrafts.net/fp6-pmos.jpg' "product og:image is the absolute photo URL"
|
||
|
||
# The about page is the person-company weld: the founder must be named in the
|
||
# HTML, the Person schema must parse, and the home page byline must link it.
|
||
body_has /about 'Jorijn van der Graaf' "about page names the founder"
|
||
body_has / 'Jorijn van der Graaf' "home page carries the founder byline"
|
||
if extract_ld /about | jq -e '.mainEntity.name == "Jorijn van der Graaf"' >/dev/null 2>&1; then
|
||
ok "about Person schema parses and names the founder"
|
||
else
|
||
bad "Person schema" "missing, unparseable, or wrong name"
|
||
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 "~". 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-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"
|
||
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-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"
|
||
# The label must not claim the Dutch rate is an EU-wide one.
|
||
body_lacks /shop/fp6-pmos 'EU VAT' "price label does not call 21% an EU-wide rate"
|
||
# The renderer loads only where a demo entry declares needsWasm — the demo LIST
|
||
# is a content page and must stay free of it.
|
||
body_has /demos/raytracer "catcrafts.wasm" "/demos/raytracer loads the wasm"
|
||
body_lacks /demos "<script" "/demos itself ships no script"
|
||
body_has /demos/raytracer 'id="webgpu-demo"' "raytracer page has the mount element"
|
||
# Exactly one chrome root: the wasm adopts the server's, never builds a second.
|
||
if [ "$(curl -s "$BASE/demos/raytracer" | grep -c 'id="catcrafts-root"')" = 1 ]; then
|
||
ok "raytracer page has exactly one chrome root"
|
||
else
|
||
bad "raytracer chrome root count" "expected 1"
|
||
fi
|
||
|
||
echo "== SSR / wasm head interaction =="
|
||
# catcrafts-head.js used to set document.title unconditionally, which replaced
|
||
# the server's per-route title with the generic site name and appended a second
|
||
# stylesheet, favicon and viewport tag. The <meta name="cc-ssr"> marker is what
|
||
# it now checks; if that marker stops being emitted the guard silently stops
|
||
# working, so assert it is present and that the head is not duplicated.
|
||
body_has /demos/raytracer 'name="cc-ssr"' "SSR marker present for head.js to detect"
|
||
body_has /demos/raytracer '<title>Real-time ray tracer' "demo page keeps its route-specific title"
|
||
for probe in 'rel="stylesheet"' 'rel="icon"' 'name="viewport"'; do
|
||
n=$(curl -s "$BASE/demos/raytracer" | grep -o "$probe" | wc -l)
|
||
if [ "$n" = 1 ]; then ok "demo page has exactly one $probe"
|
||
else bad "demo page $probe count" "expected 1, got $n"; fi
|
||
done
|
||
|
||
echo "== wasm boots at depth =="
|
||
# The bug this section exists for: /demos/raytracer is two segments deep, and
|
||
# every asset the runtime needs was referenced RELATIVE to the document —
|
||
# src="runtime.js", fetch("files.json"), fetch("variants.json"), and the .wasm
|
||
# named by variants.json. So the browser asked for /demos/runtime.js, Caddy's
|
||
# try_files handed back index.html, and the module was blocked for being
|
||
# text/html. Four NS_ERROR_CORRUPTED_CONTENT failures and a blank demo.
|
||
#
|
||
# The server emits <base href="/"> on any page that boots wasm, which fixes all
|
||
# of them at once. These checks pin that, and pin the precondition that makes it
|
||
# safe: nothing else on the page may use a relative URL.
|
||
boot=$(curl -s "$BASE/demos/raytracer" | grep -c '<script src=' || true)
|
||
if [ "$boot" -eq 0 ]; then
|
||
skip "wasm boot checks" "no bundle under bin/, so no boot scripts were emitted — build the wasm product first"
|
||
else
|
||
body_has /demos/raytracer '<base href="/">' "wasm page sets <base href=\"/\">"
|
||
# Absolute script srcs regardless of the <base>, so the tags stay correct even
|
||
# if the base is ever removed.
|
||
if curl -s "$BASE/demos/raytracer" | grep -qE '<script[[:space:]][^>]*src="[^"/:]'; then
|
||
bad "boot script paths" "a script src is relative and will 404 at depth"
|
||
curl -s "$BASE/demos/raytracer" | grep -oE '<script[^>]*src="[^"]*"' >&2
|
||
else
|
||
ok "every boot script src is absolute"
|
||
fi
|
||
# A <base> rewrites every relative URL in the document, so it is only safe
|
||
# while there are none. If a view ever emits href="x" or a bare "#frag", the
|
||
# base silently retargets it — assert the precondition rather than trusting it.
|
||
rel=$(curl -s "$BASE/demos/raytracer" \
|
||
| grep -oE '(href|src|action)="[^"]*"' \
|
||
| grep -cvE '="(/|https?://|mailto:)' || true)
|
||
if [ "$rel" -eq 0 ]; then
|
||
ok "wasm page has no relative URL for <base> to retarget"
|
||
else
|
||
bad "relative URLs under <base>" "$rel URL(s) would be retargeted by the base tag"
|
||
fi
|
||
fi
|
||
# The base tag belongs only where the runtime needs it. On a content page it is
|
||
# dead weight and one more thing that could retarget a future relative link.
|
||
body_lacks /posts '<base' "/posts has no base tag"
|
||
body_lacks /shop/fp6-pmos '<base' "/shop/<slug> has no base tag"
|
||
|
||
echo "== home page actions =="
|
||
body_has / 'Browse projects' "home links to projects"
|
||
body_has / 'Browse shop' "home links to the shop"
|
||
body_lacks / 'ray tracer' "home no longer pushes the ray tracer"
|
||
|
||
echo "== post media =="
|
||
# The media IS the content of these posts (screen recordings of the work), and it
|
||
# must come from our own origin: the privacy notice states that everything the
|
||
# browser loads comes from catcrafts.net, and a third-party embed would send
|
||
# every visitor's IP to whichever instance hosted the file.
|
||
if curl -s "$BASE/posts" | grep -qE '<(img|video) class="post-media__item"'; then
|
||
ok "/posts embeds its media"
|
||
else
|
||
bad "/posts media" "no embedded media found"
|
||
fi
|
||
# `poster` is in the list because a video poster is fetched on page load exactly
|
||
# like an <img> src is, so a third-party poster leaks the same visitor IP.
|
||
if curl -s "$BASE/posts" | grep -qE '(src|srcset|href|poster)="https?://[^"]*\.(mp4|webm|webp|avif|png|jpe?g|gif)'; then
|
||
bad "/posts media origin" "media loaded from a third party"
|
||
else
|
||
ok "/posts loads no media from a third party"
|
||
fi
|
||
# Dimensions prevent layout shift as each file arrives. Needs ffprobe at fetch
|
||
# time (see the CI package list) — a build host without it produces no
|
||
# dimensions at all, which is what this catches.
|
||
if curl -s "$BASE/posts" | grep -qE '<img class="post-media__item"[^>]*width="[0-9]+" height="[0-9]+"'; then
|
||
ok "images carry width/height"
|
||
else
|
||
bad "image dimensions" "no width/height on embedded images"
|
||
fi
|
||
# Videos too. This assertion exists because they silently lost theirs: ffprobe
|
||
# appends an empty CSV field for some files, so parsing `width,height` as one
|
||
# joined string yielded a height of "480x" and the guard discarded both.
|
||
if curl -s "$BASE/posts" | grep -qE '<video class="post-media__item"[^>]*width="[0-9]+" height="[0-9]+"'; then
|
||
ok "videos carry width/height"
|
||
else
|
||
bad "video dimensions" "no width/height on embedded videos"
|
||
fi
|
||
# A poster is the frame shown before anyone presses play, and these posts ARE
|
||
# their video. Asserting "at least one" rather than "every one": an instance that
|
||
# generated no thumbnail is a legitimate empty poster, but zero posters across
|
||
# every video means the fetch/mirror/render chain is broken.
|
||
if curl -s "$BASE/posts" | grep -qE '<video class="post-media__item"[^>]*poster="/media/'; then
|
||
ok "videos carry a locally-hosted poster"
|
||
else
|
||
bad "video poster" "no video has a poster; a black box shows until play"
|
||
fi
|
||
# A video offering an AV1 <source> must offer an H.264 one after it: the codecs
|
||
# parameter is what lets a browser without AV1 (Safari before 17, Apple hardware
|
||
# without the decoder) skip to a file it can play, and an AV1 source alone is
|
||
# exactly the "element that will not play" the fallback pipeline exists to
|
||
# prevent. Conditional — a build whose posts carry no AV1 has nothing to check.
|
||
if curl -s "$BASE/posts" | grep -q 'codecs=av01'; then
|
||
if curl -s "$BASE/posts" | grep -qE '<source src="/media/[^"]*\.h264\.mp4" type="video/mp4">'; then
|
||
ok "AV1 videos carry an H.264 fallback source"
|
||
else
|
||
bad "video fallback" "an av01 <source> has no h264 sibling"
|
||
fi
|
||
fi
|
||
# preload="metadata", not auto: several 5 MB recordings must not all download on
|
||
# page load.
|
||
if curl -s "$BASE/posts" | grep -q 'preload="metadata"'; then
|
||
ok "video does not preload its whole body"
|
||
else
|
||
bad "video preload" "expected preload=\"metadata\""
|
||
fi
|
||
|
||
echo "== post pages =="
|
||
# The post page is where the body lives, and the body is the reason the site
|
||
# has anything for a search engine to index beyond a list of links off it. Its
|
||
# slug is data, so take one from the page rather than hardcoding a title that
|
||
# will be wrong the week after it is written.
|
||
POST_PATH=$(curl -s "$BASE/posts" | grep -oE 'href="/posts/[a-z0-9-]+"' \
|
||
| head -n1 | sed 's/href="//; s/"$//')
|
||
if [ -z "$POST_PATH" ]; then
|
||
bad "post pages" "/posts links no post page; nothing carries a body"
|
||
else
|
||
ok "/posts links a post page ($POST_PATH)"
|
||
status "$POST_PATH" 200
|
||
body_has /posts 'Read the full post' "/posts offers the full post"
|
||
# And it trails the excerpt, immediately after the ellipsis the truncation
|
||
# left, rather than sitting as its own row below the media. The excerpt is
|
||
# escaped text, so nothing but the link can put a '<' between the two.
|
||
if curl -s "$BASE/posts" \
|
||
| grep -qE '<p class="post-card__excerpt">[^<]*<a class="link-more" href="/posts/'; then
|
||
ok "read-more trails the excerpt"
|
||
else
|
||
bad "read-more placement" "not inside the excerpt paragraph"
|
||
fi
|
||
body_has "$POST_PATH" '<div class="post-body">' "post page carries the rendered body"
|
||
# Rendered, not dumped: a body that reached the page as literal Markdown
|
||
# would show its own asterisks and hashes to the reader and to a crawler.
|
||
if curl -s "$BASE$POST_PATH" | grep -qE '<(p|h2|h3|h4|ul|ol|blockquote|pre)>'; then
|
||
ok "post body is real markup, not literal Markdown"
|
||
else
|
||
bad "post body markup" "no block elements found in the body"
|
||
fi
|
||
# The canonical points here, not at the instance. That is the entire SEO
|
||
# argument for hosting the body: two copies of the text exist, and this
|
||
# says which one is the original as far as this site is concerned.
|
||
body_has "$POST_PATH" 'rel="canonical" href="https://catcrafts.net/posts/' \
|
||
"post page is its own canonical"
|
||
body_has "$POST_PATH" '"@type":"BlogPosting"' "post page carries BlogPosting JSON-LD"
|
||
body_has "$POST_PATH" '"@id":"https://catcrafts.net/#organization"' \
|
||
"post JSON-LD joins the organization node"
|
||
body_has "$POST_PATH" '"@id":"https://catcrafts.net/about#person"' \
|
||
"post JSON-LD joins the founder node"
|
||
body_has "$POST_PATH" 'property="og:type" content="article"' "post page is an article to og:"
|
||
# Hosting the body does not mirror the discussion; the thread is still one
|
||
# click away and is still where the comments are.
|
||
if curl -s "$BASE$POST_PATH" | grep -qE 'href="https://[a-z0-9.-]+/post/[0-9]+"'; then
|
||
ok "post page still links its thread"
|
||
else
|
||
bad "post thread link" "no https://<instance>/post/<id> link on the post page"
|
||
fi
|
||
# The body is prose, not an application. Same rule as /projects.
|
||
body_lacks "$POST_PATH" '<script>' "post page ships no executable script"
|
||
body_lacks "$POST_PATH" '<base' "post page has no base tag"
|
||
# Every inline image and video the body embeds is mirrored, exactly like a
|
||
# card's media — the privacy notice's "everything comes from catcrafts.net"
|
||
# covers href as well as src, so a body linking a .webp on someone else's
|
||
# instance is the same leak as embedding one.
|
||
for pg in $(curl -s "$BASE/sitemap.xml" \
|
||
| grep -oE '/posts/[a-z0-9-]+' | head -n 20); do
|
||
if curl -s "$BASE$pg" \
|
||
| grep -qE '(src|srcset|href|poster)="https?://[^"]*\.(mp4|webm|webp|avif|png|jpe?g|gif)'; then
|
||
bad "$pg media origin" "body media loaded from a third party"
|
||
else
|
||
ok "$pg loads no media from a third party"
|
||
fi
|
||
done
|
||
# The image format ladder: AVIF first, the mirrored original next, and a
|
||
# PNG on the <img> underneath, so exactly one file is fetched and every
|
||
# browser can read one of them. Order is the whole point — a browser takes
|
||
# the first source it understands — so assert the sequence, not just that
|
||
# the pieces are present.
|
||
if curl -s "$BASE$POST_PATH" | grep -q '<picture>'; then
|
||
if curl -s "$BASE$POST_PATH" \
|
||
| grep -qE '<picture><source srcset="/media/[^"]+\.avif" type="image/avif">'; then
|
||
ok "inline images lead with an AVIF source"
|
||
else
|
||
bad "image ladder" "the first source is not the AVIF"
|
||
fi
|
||
if curl -s "$BASE$POST_PATH" \
|
||
| grep -qE '<img class="post-media__item"[^>]*src="/media/[^"]+\.png"'; then
|
||
ok "inline images fall back to a PNG the img itself points at"
|
||
else
|
||
bad "image fallback" "the <img> base is not a PNG"
|
||
fi
|
||
# Every tier has to be a file that exists, or the ladder serves a 404 to
|
||
# whichever browsers pick that rung — which is precisely the set of
|
||
# browsers nobody testing this site is using. The files are served by
|
||
# Caddy rather than by this server, so they are checked on disk.
|
||
if [ -z "$MEDIA_DIR" ]; then
|
||
skip "media files exist" "no media directory found; set E2E_MEDIA_DIR"
|
||
else
|
||
missing=0
|
||
for f in $(curl -s "$BASE/sitemap.xml" | grep -oE '/posts/[a-z0-9-]+' | head -n 20 \
|
||
| while read -r pg; do curl -s "$BASE$pg"; done \
|
||
| grep -oE '(src|srcset)="/media/[^"]+"' \
|
||
| sed 's/.*="//; s/"$//' | sort -u); do
|
||
if [ ! -f "$MEDIA_DIR/${f#/media/}" ]; then
|
||
missing=$((missing + 1))
|
||
# Bounded: a wrong directory makes EVERY file missing, and a
|
||
# hundred identical lines buries the one fact that matters.
|
||
[ "$missing" -le 5 ] && echo " missing: $f" >&2
|
||
fi
|
||
done
|
||
if [ "$missing" -eq 0 ]; then
|
||
ok "every referenced media file is in $MEDIA_DIR"
|
||
else
|
||
bad "media files" "$missing referenced file(s) not in $MEDIA_DIR"
|
||
fi
|
||
fi
|
||
else
|
||
skip "image format ladder" "no <picture> on this page — ffmpeg absent at mirror time?"
|
||
fi
|
||
# Inline screenshots get dimensions from the sidecar list fetch-media.sh
|
||
# writes, because Markdown syntax has nowhere to carry them — without it
|
||
# the prose below every image jumps as the file arrives. Conditional: a
|
||
# post whose body embeds nothing has nothing to check.
|
||
if curl -s "$BASE$POST_PATH" | grep -q '<img class="post-media__item"'; then
|
||
if curl -s "$BASE$POST_PATH" \
|
||
| grep -qE '<img class="post-media__item"[^>]*width="[0-9]+" height="[0-9]+"'; then
|
||
ok "inline body images carry width/height"
|
||
else
|
||
bad "inline image dimensions" "an embedded body image has no dimensions"
|
||
fi
|
||
fi
|
||
fi
|
||
# The sitemap has to advertise the pages, or hosting the bodies buys nothing.
|
||
if curl -s "$BASE/sitemap.xml" | grep -qE '<loc>https://catcrafts.net/posts/[a-z0-9-]+</loc>'; then
|
||
ok "sitemap lists the post pages"
|
||
else
|
||
bad "sitemap post pages" "no /posts/<slug> entry"
|
||
fi
|
||
|
||
echo "== headers =="
|
||
header_has / 'x-content-type-options: *nosniff' "nosniff on pages"
|
||
header_has / 'cache-control: *public' "pages are cacheable"
|
||
header_has /nope 'x-robots-tag: *noindex' "404 is noindex"
|
||
header_has /feed.xml 'content-type: *application/atom' "feed content-type"
|
||
header_has /sitemap.xml 'content-type: *application/xml' "sitemap content-type"
|
||
|
||
echo "== sitemap and feed content =="
|
||
body_has /sitemap.xml "/shop/fp6-pmos" "sitemap lists the product"
|
||
body_has /sitemap.xml "/about" "sitemap lists the about page"
|
||
body_has /sitemap.xml "/legal/privacy" "sitemap lists the privacy page"
|
||
body_has /sitemap.xml "/demos" "sitemap lists the demos page"
|
||
body_has /sitemap.xml "/financials" "sitemap lists the financials page"
|
||
|
||
echo "== the financials page =="
|
||
# Aggregate-only by construction: totals and counts, machine-readable for
|
||
# this suite via the data-fin-* attributes. Live is the page's promise, so
|
||
# it must never sit in a shared cache.
|
||
header_has /financials 'cache-control: *no-store' "financials are never cached"
|
||
body_has /financials 'data-fin-sales-count="0"' "financials start at zero sales"
|
||
# Before the bank-aggregates file exists the page says so, and publishes no
|
||
# donation figures at all — an unknowable €0 would be a lie.
|
||
body_has /financials 'not been published yet' "unpublished bank figures say so"
|
||
body_lacks /financials 'data-fin-donations-count' "no donation figures before the file exists"
|
||
# The aggregates file appears, exactly as the owner's tooling will write it,
|
||
# and the very next request reflects it: no restart, no cache, no delay —
|
||
# this is the liveness the donation counter depends on.
|
||
cat >"$ORDERS.financials.json" <<'JSON'
|
||
{"as_of":"2026-08-14",
|
||
"donations":{"count":3,"total_minor":4500},
|
||
"recurring":[{"label":"Hosting","total_minor":1200}],
|
||
"single":[{"label":"Inventory","total_minor":230000}]}
|
||
JSON
|
||
body_has /financials 'data-fin-donations-count="3"' "donation count picked up live"
|
||
body_has /financials 'data-fin-expenses-minor="231200"' "expense total picked up live"
|
||
body_has /financials 'Hosting' "recurring category renders"
|
||
body_has /financials 'Inventory' "one-off category renders"
|
||
body_has /financials '2026-08-14' "bank figures carry their as-of date"
|
||
|
||
echo "== the bunq mutation callback =="
|
||
# The live path: bunq PUSHES a mutation, the server classifies it against the
|
||
# rules file and folds it into the totals. This is what makes a donation tick
|
||
# the public counter while the donor is still looking at the page.
|
||
#
|
||
# The rules are written here rather than at startup on purpose — they are
|
||
# re-read per callback, so a new rule takes effect without a restart.
|
||
cat >"$ORDERS.financial-rules.json" <<'JSON'
|
||
{"donation_accounts":[9911],
|
||
"rules":[{"description_contains":"hetzner","group":"recurring","label":"Hosting"},
|
||
{"iban":"NL01OWNSELF0000000","group":"ignore"}]}
|
||
JSON
|
||
|
||
CB="/api/bunq/$BUNQ_CALLBACK_SECRET"
|
||
# bunq_post <id> <account> <value> <iban> <description> -> HTTP status
|
||
bunq_post() {
|
||
curl -s -o /dev/null -w '%{http_code}' -X POST \
|
||
-H 'content-type: application/json' \
|
||
--data-binary "$(printf '{"NotificationUrl":{"category":"MUTATION","event_type":"MUTATION_CREATED","object":{"Payment":{"id":%s,"created":"2026-08-15 09:31:02.000000","monetary_account_id":%s,"amount":{"currency":"EUR","value":"%s"},"description":"%s","counterparty_alias":{"iban":"%s","display_name":"Someone"}}}}}' \
|
||
"$1" "$2" "$3" "$5" "$4")" \
|
||
"$BASE$CB"
|
||
}
|
||
# fin_attr <attribute> -> its value on the live page
|
||
fin_attr() { curl -s "$BASE/financials" | grep -o "$1=\"[0-9]*\"" | cut -d'"' -f2; }
|
||
|
||
# An endpoint guarded by a secret must not confirm its own existence: every
|
||
# unauthorised shape is the same 404 an unknown order token gets.
|
||
status "/api/bunq/wrong-secret" 404 POST '{}'
|
||
status "$CB" 404 # GET on the right URL is still not a callback
|
||
status "$CB" 404 HEAD
|
||
|
||
# A donation arrives on the donation account. No rule names the sender —
|
||
# donors are strangers, which is exactly why the account is what classifies.
|
||
if [ "$(bunq_post 4823 9911 25.00 NL55BUNQ2025123456 'Thanks for imsd')" = 200 ]; then
|
||
ok "the callback accepts a mutation"
|
||
else
|
||
bad "bunq callback" "a valid notification was not accepted"
|
||
fi
|
||
if [ "$(fin_attr data-fin-donations-count)" = 4 ] \
|
||
&& [ "$(fin_attr data-fin-donations-minor)" = 7000 ]; then
|
||
ok "a donation ticks the public counter immediately"
|
||
else
|
||
bad "donation ingest" "counter did not move to 4 / 7000 cents"
|
||
fi
|
||
# bunq redelivers a callback it did not see a 2xx for, and can redeliver one
|
||
# it did. Counting that twice would publish money that never arrived.
|
||
bunq_post 4823 9911 25.00 NL55BUNQ2025123456 'Thanks for imsd' >/dev/null
|
||
if [ "$(fin_attr data-fin-donations-count)" = 4 ] \
|
||
&& [ "$(fin_attr data-fin-donations-minor)" = 7000 ]; then
|
||
ok "a redelivered mutation is not counted twice"
|
||
else
|
||
bad "callback idempotency" "a duplicate mutation moved the totals"
|
||
fi
|
||
# Default-deny: money no rule claims is WITHHELD from the page. It is logged
|
||
# for classification, never published as a guess.
|
||
if [ "$(bunq_post 4824 1234 90.00 NL99UNKNOWN00000000 'unlabelled transfer')" = 200 ]; then
|
||
ok "an unclassifiable mutation is still accepted (no redelivery loop)"
|
||
else
|
||
bad "unclassified mutation" "the callback answered non-2xx and will be retried forever"
|
||
fi
|
||
if [ "$(fin_attr data-fin-donations-count)" = 4 ] \
|
||
&& [ "$(fin_attr data-fin-expenses-minor)" = 231200 ]; then
|
||
ok "an unclassified mutation is withheld from every total"
|
||
else
|
||
bad "default-deny" "an unmatched mutation reached the public figures"
|
||
fi
|
||
# An outgoing bill matched by description becomes a positive expense.
|
||
bunq_post 4825 9911 -12.00 DE00HETZNER00000000 'HETZNER ONLINE GMBH' >/dev/null
|
||
if [ "$(fin_attr data-fin-expenses-minor)" = 232400 ]; then
|
||
ok "an outgoing bill lands in its expense category"
|
||
else
|
||
bad "expense ingest" "expenses did not move to 232400 cents"
|
||
fi
|
||
body_has /financials '2026-08-15' "the as-of date advances with the mutations"
|
||
# The page still publishes nothing but aggregates: no counterparty, no
|
||
# description, no id, no timestamp. This is the assertion that would catch a
|
||
# well-meant future edit adding a "recent activity" list.
|
||
for leak in 'NL55BUNQ2025123456' 'Someone' 'Thanks for imsd' '4823' '09:31'; do
|
||
body_lacks /financials "$leak" "financials leak no transaction detail ($leak)"
|
||
done
|
||
# And nothing identifying was written to disk either — the ingest ledger holds
|
||
# opaque ids and counters, and no other file learned the donor exists.
|
||
if grep -rlF 'NL55BUNQ2025123456' "$WORK" >/dev/null 2>&1; then
|
||
bad "callback storage" "a counterparty IBAN was persisted somewhere under $WORK"
|
||
else
|
||
ok "no counterparty IBAN is persisted anywhere"
|
||
fi
|
||
|
||
echo "== instance-agnostic copy =="
|
||
# The account lives on one instance but posts go into communities on others, so
|
||
# no page should name a specific instance as though it were the home of the
|
||
# discussion.
|
||
# In visible text, not in href values — a post's own permalink necessarily
|
||
# contains an instance name, and that is not what this is about. Strip tags and
|
||
# check the prose.
|
||
for pg in / /posts /shop; do
|
||
if curl -s "$BASE$pg" | sed 's/<[^>]*>/ /g' | grep -qi 'ani\.social'; then
|
||
bad "$pg names an instance in visible text" "found ani.social in prose"
|
||
else
|
||
ok "$pg names no specific instance in visible text"
|
||
fi
|
||
done
|
||
body_has /posts "fediverse" "/posts refers to the fediverse generally"
|
||
# The fediverse account is not advertised at all — only individual posts are.
|
||
body_lacks / "/u/" "footer does not link a fediverse profile"
|
||
body_lacks /posts "/u/" "/posts links no account profile, only threads"
|
||
|
||
# Every outbound thread link is a real permalink: absolute https, on some
|
||
# instance, pointing at a numeric post id. fetch-posts.sh resolves these against
|
||
# the COMMUNITY's instance rather than the author's, because that is where the
|
||
# discussion is — but a resolution failure legitimately falls back to the
|
||
# author's copy, so this checks the shape rather than naming a host.
|
||
links=$(curl -s "$BASE/posts" | grep -oE 'href="https://[a-z0-9.-]+/post/[0-9]+"' | wc -l)
|
||
if [ "$links" -gt 0 ]; then
|
||
ok "/posts links $links threads by permalink"
|
||
else
|
||
bad "post permalinks" "no https://<instance>/post/<id> link found"
|
||
fi
|
||
# Nothing should link a post by a bare id or a relative path — that would mean a
|
||
# permalink was rendered without its origin and silently resolves to catcrafts.net.
|
||
if curl -s "$BASE/posts" | grep -qE 'href="/post/[0-9]+"'; then
|
||
bad "post permalinks" "a thread link lost its instance and points at us"
|
||
else
|
||
ok "no thread link resolves to catcrafts.net"
|
||
fi
|
||
body_lacks /sitemap.xml "/blog" "sitemap does not advertise the redirect"
|
||
body_lacks /sitemap.xml "/order" "sitemap does not advertise order pages"
|
||
body_has /feed.xml "<feed xmlns=\"http://www.w3.org/2005/Atom\">" "feed is Atom"
|
||
|
||
# SHOP_OPEN was probed above (before the script-shape checks). The checkout,
|
||
# order-lifecycle and invoice suites below only run when the shop is open; the
|
||
# coming-soon branch asserts the closed state instead. Launch day (status flip
|
||
# to "available" in Catcrafts.Shared-Content.cppm) re-arms the full suite with
|
||
# no e2e edit.
|
||
|
||
echo "== the shop front =="
|
||
# The price is rendered from the same integers the checkout charges, with the
|
||
# derived ex-VAT twin alongside — asserting both pins the arithmetic.
|
||
body_has /shop/fp6-pmos '€563.30' "product page shows the from-price (green supplier + €50)"
|
||
body_has /shop/fp6-pmos '€465.54' "product page shows the derived ex-VAT price"
|
||
body_has /shop/fp6-pmos '>from<' "product page marks the price as a from-price"
|
||
body_has /shop '€563.30' "shop card shows the from-price"
|
||
# Every colour is priced in the selector, and the form carries the exact data
|
||
# blob the preview computes from.
|
||
body_has /shop/fp6-pmos 'Black — €569.30' "colour selector prices black"
|
||
body_has /shop/fp6-pmos 'White — €654.88' "colour selector prices white"
|
||
if [ "$SHOP_OPEN" = 1 ]; then
|
||
body_has /shop/fp6-pmos 'data-cc=' "form embeds the pricing blob"
|
||
body_has /shop/fp6-pmos 'id="cc-total"' "live total element present"
|
||
else
|
||
body_has /shop/fp6-pmos 'Coming soon' "coming-soon notice on the buy panel"
|
||
body_has /shop 'coming soon' "shop card carries the coming-soon badge"
|
||
body_lacks /shop/fp6-pmos '<form' "no order form while coming soon"
|
||
fi
|
||
body_has /shop/fp6-pmos 'src="/fp6-pmos.jpg"' "product page embeds the photo"
|
||
body_has /shop 'src="/fp6-pmos.jpg"' "shop card embeds the thumbnail"
|
||
# The image file itself is Caddy's to serve (static asset), so its presence is
|
||
# asserted against the repo, not this server.
|
||
if [ -f images/fp6-pmos.jpg ]; then
|
||
ok "product photo exists in the repo"
|
||
else
|
||
bad "product photo" "images/fp6-pmos.jpg missing"
|
||
fi
|
||
body_has /shop/fp6-pmos 'not yet verified' "emergency-calling caveat is on the page"
|
||
body_lacks /shop 'reservation' "no reservation copy survives on /shop"
|
||
body_lacks /shop/fp6-pmos 'Reserve one' "no reservation form survives"
|
||
|
||
GOOD='email=e2e%40example.org&name=Ada%20Lovelace&street=Main%20St%201&postal=1234AB&city=Delft&country=nl'
|
||
|
||
if [ "$SHOP_OPEN" = 1 ]; then
|
||
|
||
echo "== checkout =="
|
||
|
||
# A valid submission answers 303 straight to the PAYMENT page — no interim
|
||
# stop. The fake rail's payUrl is the order page itself, so the token is
|
||
# still extractable from the Location and the browser flow works in dev.
|
||
LOC=$(curl -s -o /dev/null -w '%{redirect_url}' -X POST -d "$GOOD" "$BASE/shop/fp6-pmos")
|
||
TOKEN=$(printf '%s' "$LOC" | grep -oE '/order/[0-9a-f]{32}$' | cut -d/ -f3 || true)
|
||
if [ -n "$TOKEN" ]; then
|
||
ok "POST checkout -> 303 straight to payment"
|
||
else
|
||
bad "checkout redirect" "Location was: $LOC"
|
||
fi
|
||
if grep -q '"country":"NL"' "$ORDERS" && grep -q '"total_minor":57830' "$ORDERS"; then
|
||
ok "order stored: NL total is €578.30 (green €563.30 + €15 shipping)"
|
||
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.
|
||
ORDER_HTML=$(curl -s "$BASE/order/$TOKEN")
|
||
printf '%s' "$ORDER_HTML" > "$WORK/order.html"
|
||
for probe in 'awaiting payment' 'Resume payment' 'CC-' 'http-equiv="refresh"' '€578.30'; do
|
||
if grep -qF -- "$probe" "$WORK/order.html"; then
|
||
ok "order page has $probe"
|
||
else
|
||
bad "order page" "missing: $probe"
|
||
fi
|
||
done
|
||
header_has "/order/$TOKEN" 'x-robots-tag: *noindex' "order page is noindex"
|
||
header_has "/order/$TOKEN" 'cache-control: *no-store' "order page is never cached"
|
||
|
||
# Unknown and malformed tokens are the same 404.
|
||
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. 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' "$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' "$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' "$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 ≈ £ line on the GB order page"
|
||
fi
|
||
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 "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=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
|
||
W_HTML=$(curl -s "$BASE/order/$TOKEN_W")
|
||
if printf '%s' "$W_HTML" | grep -qF '€1137.45'; then
|
||
ok "white ×2 export total nets the line, not the unit"
|
||
else
|
||
bad "variant qty total" "€1137.45 not on the page"
|
||
fi
|
||
if printf '%s' "$W_HTML" | grep -qF 'Device × 2'; then
|
||
ok "order page shows the quantity"
|
||
else
|
||
bad "order quantity display" "no 'Device × 2'"
|
||
fi
|
||
if printf '%s' "$W_HTML" | grep -qF 'White'; then
|
||
ok "order page names the colour"
|
||
else
|
||
bad "order colour display" "colour label missing"
|
||
fi
|
||
else
|
||
bad "white checkout" "no token from Location: $LOC_W"
|
||
fi
|
||
|
||
# A colour we never listed must not buy anything, whatever the form claims.
|
||
status /shop/fp6-pmos 422 POST "$GOOD&color=mauve"
|
||
status /shop/fp6-pmos 422 POST "$GOOD&quantity=100"
|
||
status /shop/fp6-pmos 422 POST "$GOOD&quantity=0"
|
||
# Quantity is a free input with a technical ceiling, not a dropdown - a
|
||
# nine-unit order is business, not fraud.
|
||
LOC_9=$(curl -s -o /dev/null -w '%{redirect_url}' -X POST \
|
||
-d "$GOOD&quantity=9" "$BASE/shop/fp6-pmos")
|
||
if printf '%s' "$LOC_9" | grep -qE '/order/[0-9a-f]{32}$'; then
|
||
ok "a nine-unit order goes through"
|
||
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"
|
||
# 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
|
||
status /order/00000000000000000000000000000000/invoice.md 404
|
||
|
||
# The payment lands: create the fake rail's paid marker, then the reconciler
|
||
# (1 s cadence in fake mode) must flip the order within a few seconds.
|
||
touch "$ORDERS.fake-paid"
|
||
# The paid state shows the confirmation notice, deliberately WITHOUT a second
|
||
# "paid" badge — so the success marker is the notice text.
|
||
i=0
|
||
until curl -s "$BASE/order/$TOKEN" | grep -q 'order is confirmed'; do
|
||
i=$((i + 1))
|
||
if [ "$i" -gt 40 ]; then break; fi
|
||
sleep 0.25
|
||
done
|
||
if curl -s "$BASE/order/$TOKEN" | grep -q 'order is confirmed'; then
|
||
ok "order confirms after payment (arrival poll or reconciler)"
|
||
else
|
||
bad "reconciler" "order still not confirmed 10s after the marker appeared"
|
||
fi
|
||
n_badges=$(curl -s "$BASE/order/$TOKEN" | grep -c 'badge--active' || true)
|
||
if [ "$n_badges" = 0 ]; then
|
||
ok "no duplicate paid badge next to the confirmation"
|
||
else
|
||
bad "badge dedupe" "found $n_badges active badges on the paid page"
|
||
fi
|
||
if curl -s "$BASE/order/$TOKEN" | grep -q 'http-equiv="refresh"'; then
|
||
bad "paid page refresh" "a settled order page still self-refreshes"
|
||
else
|
||
ok "paid order page stops self-refreshing"
|
||
fi
|
||
if grep -q '"type":"status"' "$ORDERS" && grep -q '"status":"paid"' "$ORDERS"; then
|
||
ok "paid transition is an appended event, not a rewrite"
|
||
else
|
||
bad "order event log" "no status event found in $ORDERS"
|
||
fi
|
||
# The paid event records HOW it was paid — card money stays reversible for
|
||
# months, so the ledger must show which orders carry that tail.
|
||
if grep -q '"via":"fake"' "$ORDERS"; then
|
||
ok "paid event records the payment method"
|
||
else
|
||
bad "payment method" "no via field on the paid event"
|
||
fi
|
||
|
||
echo "== the signed invoice =="
|
||
# Paid orders download a clearsigned markdown invoice: sequential number,
|
||
# registered identity, amounts — and a signature that verifies offline.
|
||
curl -s -D "$WORK/inv-headers" "$BASE/order/$TOKEN/invoice.md" > "$WORK/invoice.md"
|
||
for probe in 'BEGIN PGP SIGNED MESSAGE' '# Invoice ' 'Customer number: ' \
|
||
'Chico Mendesring 256' 'KVK 78437059' \
|
||
'NL003329281B38' 'CC-' 'VAT 21% (NL)' '€578.30'; do
|
||
if grep -qF -- "$probe" "$WORK/invoice.md"; then
|
||
ok "invoice has $probe"
|
||
else
|
||
bad "invoice content" "missing: $probe"
|
||
fi
|
||
done
|
||
if grep -qi 'content-disposition: *attachment' "$WORK/inv-headers"; then
|
||
ok "invoice downloads as an attachment"
|
||
else
|
||
bad "invoice headers" "no attachment disposition"
|
||
fi
|
||
if gpg --verify "$WORK/invoice.md" >/dev/null 2>&1; then
|
||
ok "invoice signature verifies with gpg"
|
||
else
|
||
bad "invoice signature" "gpg --verify failed"
|
||
fi
|
||
# Four orders were placed before the marker (two of them by the same email);
|
||
# the arrival poll paid one instantly, the reconciler sweeps the rest on its
|
||
# 1 s cadence — wait for all four invoices before judging the numbering.
|
||
i=0
|
||
until [ "$(grep -c '"type":"invoice"' "$ORDERS" || true)" -ge 4 ]; do
|
||
i=$((i + 1))
|
||
if [ "$i" -gt 40 ]; then break; fi
|
||
sleep 0.25
|
||
done
|
||
|
||
# Per-customer series, continuing the pre-shop administration: numbers are
|
||
# <customer-uuid>-<seq>, unique overall, and orders that share an email share
|
||
# a series with distinct sequence numbers.
|
||
n_inv=$(grep -c '"type":"invoice"' "$ORDERS" || true)
|
||
n_uniq=$(grep -o '"number":"[0-9a-f-]*"' "$ORDERS" | sort -u | wc -l)
|
||
if [ "$n_inv" -gt 0 ] && [ "$n_inv" = "$n_uniq" ]; then
|
||
ok "invoice numbers are unique ($n_inv issued)"
|
||
else
|
||
bad "invoice numbering" "$n_inv events, $n_uniq unique numbers"
|
||
fi
|
||
if grep -o '"number":"[0-9a-f-]*"' "$ORDERS" | grep -qE '"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12}-[0-9]+"$'; then
|
||
ok "invoice numbers are customer-uuid series"
|
||
else
|
||
bad "invoice format" "no <uuid v4>-<seq> shaped number in the ledger"
|
||
fi
|
||
# The GOOD email placed several paid orders in this run — all of them must sit
|
||
# in ONE customer series (same uuid), with as many distinct sequence numbers.
|
||
n_customers=$(grep -o '"customer":"[0-9a-f-]*"' "$ORDERS" | sort -u | wc -l)
|
||
n_orders_series=$(grep -c '"type":"invoice"' "$ORDERS")
|
||
if [ "$n_customers" -lt "$n_orders_series" ]; then
|
||
ok "repeat customer shares one series ($n_customers customers, $n_orders_series invoices)"
|
||
else
|
||
bad "customer series" "every invoice got its own customer uuid — series not shared"
|
||
fi
|
||
|
||
echo "== the confirmation email =="
|
||
# Every paid order gets exactly one confirmation with the signed invoice
|
||
# 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 "$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 "$n_paid" ]; then
|
||
ok "one confirmation email per paid order ($n_mail sent)"
|
||
else
|
||
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
|
||
# placed two orders, so the address alone would be ambiguous).
|
||
MAIL=$(grep -l "/order/$TOKEN" "$WORK"/mail-*.eml 2>/dev/null | head -n1)
|
||
if [ -n "$MAIL" ]; then
|
||
for probe in 'To: e2e@example.org' 'Subject: Catcrafts order CC-' \
|
||
'From: Catcrafts <info@catcrafts.net>' 'MIME-Version: 1.0' \
|
||
'€578.30' 'incl. 21% NL VAT' 'KVK 78437059' \
|
||
'BEGIN PGP SIGNED MESSAGE' 'filename="catcrafts-invoice-'; do
|
||
if grep -qF -- "$probe" "$MAIL"; then
|
||
ok "email has $probe"
|
||
else
|
||
bad "email content" "missing: $probe"
|
||
fi
|
||
done
|
||
# The attached invoice must verify offline exactly like the download.
|
||
sed -n '/BEGIN PGP SIGNED MESSAGE/,/END PGP SIGNATURE/p' "$MAIL" > "$WORK/mail-invoice.asc"
|
||
if gpg --verify "$WORK/mail-invoice.asc" >/dev/null 2>&1; then
|
||
ok "emailed invoice signature verifies with gpg"
|
||
else
|
||
bad "emailed invoice signature" "gpg --verify failed"
|
||
fi
|
||
else
|
||
bad "confirmation email" "no message links /order/$TOKEN"
|
||
fi
|
||
|
||
# The export order's message states the VAT treatment its invoice carries.
|
||
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 GB order, or no VAT note in it"
|
||
fi
|
||
|
||
# Idempotency comes from the ledger's notified event, not from luck in
|
||
# timing — sit out two more sweeps and expect no fifth message.
|
||
sleep 5
|
||
n_after=$(ls "$WORK"/mail-*.eml 2>/dev/null | wc -l)
|
||
if [ "$n_after" = "$n_mail" ]; then
|
||
ok "no order was emailed twice"
|
||
else
|
||
bad "email idempotency" "message count grew from $n_mail to $n_after"
|
||
fi
|
||
if grep -q '"type":"notified"' "$ORDERS"; then
|
||
ok "notified events recorded in the ledger"
|
||
else
|
||
bad "notified event" "no notified event in $ORDERS"
|
||
fi
|
||
|
||
echo "== financials reflect the ledger =="
|
||
# Lifetime sales on /financials must equal the ledger: sum of total_minor over
|
||
# orders that have a paid status event. Derived from the ledger rather than
|
||
# written as a literal — same rule as the email count above, and for the same
|
||
# reason: "the page equals the ledger" is the actual property.
|
||
want_minor=0; want_count=0
|
||
for pid in $(grep '"type":"status"' "$ORDERS" | grep '"status":"paid"' \
|
||
| grep -o '"id":"[0-9a-f]\{32\}"' | grep -o '[0-9a-f]\{32\}' | sort -u); do
|
||
t=$(grep '"type":"order"' "$ORDERS" | grep -F "\"id\":\"$pid\"" \
|
||
| grep -o '"total_minor":[0-9]*' | head -n1 | cut -d: -f2)
|
||
want_minor=$((want_minor + t)); want_count=$((want_count + 1))
|
||
done
|
||
fin_page=$(curl -s "$BASE/financials")
|
||
got_minor=$(printf '%s' "$fin_page" | grep -o 'data-fin-sales-minor="[0-9]*"' | cut -d'"' -f2)
|
||
got_count=$(printf '%s' "$fin_page" | grep -o 'data-fin-sales-count="[0-9]*"' | cut -d'"' -f2)
|
||
if [ "$want_count" -gt 0 ] && [ "$got_minor" = "$want_minor" ] && [ "$got_count" = "$want_count" ]; then
|
||
ok "sales totals equal the ledger ($want_count orders, $want_minor cents)"
|
||
else
|
||
bad "financials sales" "ledger says $want_count/$want_minor, page says $got_count/$got_minor"
|
||
fi
|
||
# And the formatted euro figure for that total appears on the page.
|
||
if [ $((want_minor % 100)) -eq 0 ]; then
|
||
eur=$(printf '€%d' $((want_minor / 100)))
|
||
else
|
||
eur=$(printf '€%d.%02d' $((want_minor / 100)) $((want_minor % 100)))
|
||
fi
|
||
if printf '%s' "$fin_page" | grep -qF -- "$eur"; then
|
||
ok "sales total renders as $eur"
|
||
else
|
||
bad "financials formatting" "page lacks $eur"
|
||
fi
|
||
|
||
else
|
||
echo "== checkout (coming soon) =="
|
||
# A perfectly valid order must be refused while the shop is closed: after
|
||
# validation (so the field checks below still exercise the parser) and before
|
||
# any rail or ledger is touched.
|
||
status /shop/fp6-pmos 409 POST "$GOOD"
|
||
if [ -s "$ORDERS" ]; then
|
||
bad "coming-soon ledger" "a refused order still wrote to $ORDERS"
|
||
else
|
||
ok "refused order writes nothing to the ledger"
|
||
fi
|
||
skip "checkout, order-lifecycle and invoice suites" "shop is coming-soon; they re-arm when the status flips to available"
|
||
fi
|
||
|
||
echo "== checkout validation =="
|
||
status /shop/fp6-pmos 422 POST 'name=Ada&street=x&postal=1&city=y&country=NL' # no email
|
||
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
|
||
|
||
# The re-rendered form only exists when the shop is open; while coming-soon a
|
||
# rejection answers with the coming-soon page instead.
|
||
if [ "$SHOP_OPEN" = 1 ]; then
|
||
# A rejected submission must come back with the values still in it — losing a
|
||
# filled-in form is how a sale gets abandoned.
|
||
curl -s -X POST -d 'email=bad&name=Ada&street=Main%201&postal=1234AB&city=Delft&country=NLD' \
|
||
"$BASE/shop/fp6-pmos" > "$WORK/rejected.html"
|
||
for probe in 'value="bad"' 'value="NLD"' 'value="Ada"' 'value="Main 1"' 'value="Delft"'; do
|
||
if grep -qF -- "$probe" "$WORK/rejected.html"; then
|
||
ok "rejected form preserves $probe"
|
||
else
|
||
bad "rejected form field" "lost: $probe"
|
||
fi
|
||
done
|
||
if grep -qF 'field__error' "$WORK/rejected.html"; then
|
||
ok "rejected form shows a field error"
|
||
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 '"x":["US","CA"]' \
|
||
"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.
|
||
curl -s -X POST -d "$GOOD&website=x" "$BASE/shop/fp6-pmos" > "$WORK/pot.html"
|
||
notice=$(grep -o 'notice--error">[^<]*' "$WORK/pot.html" || true)
|
||
if [ -z "$notice" ]; then
|
||
bad "honeypot rejection" "no error notice rendered"
|
||
elif printf '%s' "$notice" | grep -qiE 'honeypot|website|hidden|trap'; then
|
||
bad "honeypot disclosure" "the error notice names the trap: $notice"
|
||
else
|
||
ok "honeypot failure does not name the trap"
|
||
fi
|
||
fi
|
||
|
||
echo "== abuse =="
|
||
status /shop/fp6-pmos 413 POST "email=a%40b.example&name=$(head -c 20000 /dev/zero | tr '\0' 'x')&street=x&postal=1&city=y&country=NL"
|
||
if curl -s -o /dev/null -w '%{http_code}' -X POST -H 'content-type: application/json' \
|
||
-d '{}' "$BASE/shop/fp6-pmos" | grep -q 415; then
|
||
ok "POST with a JSON content-type -> 415"
|
||
else
|
||
bad "content-type check" "expected 415"
|
||
fi
|
||
# HEAD must not be a 500 or a body — some crawlers use it exclusively.
|
||
status / 200 HEAD
|
||
|
||
echo
|
||
if [ "$skipped" -gt 0 ]; then
|
||
echo "e2e: $pass passed, $fail failed, $skipped skipped"
|
||
else
|
||
echo "e2e: $pass passed, $fail failed"
|
||
fi
|
||
[ "$fail" -eq 0 ] || exit 1
|