2026-08-05 04:18:37 +02:00
|
|
|
|
#!/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"
|
|
|
|
|
|
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 BUNQ_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'
|
|
|
|
|
|
|
|
|
|
|
|
"$SERVER" --serve "$PORT" --orders="$ORDERS" --rail=fake >"$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 =="
|
2026-08-05 07:05:00 +02:00
|
|
|
|
for p in / /about /shop /shop/fp6-pmos /projects /posts /demos /demos/raytracer \
|
2026-08-05 04:18:37 +02:00
|
|
|
|
/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
|
|
|
|
|
|
# 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
|
|
|
|
|
|
|
2026-08-05 07:05:00 +02:00
|
|
|
|
# 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
|
|
|
|
|
|
|
2026-08-05 04:18:37 +02:00
|
|
|
|
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"
|
|
|
|
|
|
|
|
|
|
|
|
# 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; do
|
|
|
|
|
|
body_lacks "$pg" 'PLACEHOLDER' "$pg ships no placeholder markers"
|
|
|
|
|
|
done
|
|
|
|
|
|
|
|
|
|
|
|
# Shop pages are the one exception to script-free: they carry exactly ONE
|
2026-08-05 07:05:00 +02:00
|
|
|
|
# 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.
|
2026-08-05 04:18:37 +02:00
|
|
|
|
for pg in /shop /shop/fp6-pmos; do
|
2026-08-05 07:05:00 +02:00
|
|
|
|
n=$(curl -s "$BASE$pg" | grep -c '<script>' || true)
|
2026-08-05 04:18:37 +02:00
|
|
|
|
if [ "$n" = 1 ]; then
|
2026-08-05 07:05:00 +02:00
|
|
|
|
ok "$pg carries exactly one executable script (the price hint)"
|
2026-08-05 04:18:37 +02:00
|
|
|
|
else
|
2026-08-05 07:05:00 +02:00
|
|
|
|
bad "$pg script count" "expected 1 bare <script>, got $n"
|
2026-08-05 04:18:37 +02:00
|
|
|
|
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"
|
|
|
|
|
|
|
2026-08-05 07:05:00 +02:00
|
|
|
|
# ── 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">//'
|
|
|
|
|
|
}
|
2026-08-06 23:42:25 +02:00
|
|
|
|
# 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
|
2026-08-05 07:05:00 +02:00
|
|
|
|
ok "home Organization schema parses and carries the VAT identity"
|
|
|
|
|
|
else
|
|
|
|
|
|
bad "Organization schema" "missing, unparseable, or wrong identity"
|
|
|
|
|
|
fi
|
2026-08-06 23:42:25 +02:00
|
|
|
|
# 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"
|
2026-08-05 07:05:00 +02:00
|
|
|
|
else
|
2026-08-06 23:42:25 +02:00
|
|
|
|
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"
|
2026-08-05 07:05:00 +02:00
|
|
|
|
fi
|
|
|
|
|
|
body_has /shop/fp6-pmos '"price":"563.30"' "schema price is the checkout integer"
|
2026-08-05 23:52:40 +02:00
|
|
|
|
# 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.
|
|
|
|
|
|
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"
|
2026-08-06 23:42:25 +02:00
|
|
|
|
if extract_ld /shop/fp6-pmos | jq -e '.hasVariant[0].offers.shippingDetails | length == 3' >/dev/null 2>&1; then
|
2026-08-05 23:52:40 +02:00
|
|
|
|
ok "shipping details cover all three zones"
|
|
|
|
|
|
else
|
2026-08-06 23:42:25 +02:00
|
|
|
|
bad "shipping zones" "expected NL + EU + world tiers in the first variant's offer"
|
2026-08-05 23:52:40 +02:00
|
|
|
|
fi
|
2026-08-05 07:05:00 +02:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-08-05 04:18:37 +02:00
|
|
|
|
# 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.
|
|
|
|
|
|
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-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$
|
|
|
|
|
|
# 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 '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|href|poster)="https?://[^"]*\.(mp4|webm|webp|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
|
media: H.264 fallback beside every AV1, and the post links the H.264
The AV1-only publish lasted four hours in the wild: the first viewer on
mobile Safari got "bad media error" from the raw file, because a post's
link is fetched raw — Lemmy apps and browsers play that exact URL, with
no <source> negotiation in front of it. The previous commit's note
("--raw ... if that audience matters") had it backwards: the audience
that cannot play AV1 is not a per-post judgement call, it is whoever
happens to open the thread on an iPhone.
So publish-media.sh now emits two encodings and points the post at the
compatible one:
* Every video transcode also produces <hash>.h264.mp4 (x264 crf 23,
same denoise, same frames), uploaded beside the AV1 under the AV1's
hash — the poster's sibling-naming trick, reused, so fetch-media.sh
finds it by name with nothing to look up.
* The printed URL to paste into the post is the H.264 one. pict-rs
can thumbnail it too, so instance thumbnails come back as a bonus.
fetch-media.sh adopting an own-origin .h264.mp4 URL swaps the AV1 back
in as the page's primary when it is on the mount, and records the H.264
as `fallback` in posts.json. The renderer turns a non-empty fallback
into a <source> pair: the AV1 first with an explicit codecs parameter —
both files are video/mp4, so the parameter is the only thing that lets
a non-AV1 browser skip to the file it can play — and the H.264 second.
Browsers with AV1 keep downloading the small file; Safari before 17
gets one that plays instead of an element that will not.
e2e gains the matching conditional gate: a page offering an av01
<source> must offer an .h264.mp4 one, so an AV1 video can never again
ship without its fallback.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 03:14:51 +02:00
|
|
|
|
# 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
|
2026-08-05 04:18:37 +02:00
|
|
|
|
# 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 "== 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"
|
2026-08-05 07:05:00 +02:00
|
|
|
|
body_has /sitemap.xml "/about" "sitemap lists the about page"
|
2026-08-05 04:18:37 +02:00
|
|
|
|
body_has /sitemap.xml "/legal/privacy" "sitemap lists the privacy page"
|
|
|
|
|
|
body_has /sitemap.xml "/demos" "sitemap lists the demos page"
|
|
|
|
|
|
|
|
|
|
|
|
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"
|
|
|
|
|
|
|
2026-08-05 07:05:00 +02:00
|
|
|
|
# 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.
|
2026-08-05 04:18:37 +02:00
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
# 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
|
|
|
|
|
|
|
|
|
|
|
|
# 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")
|
|
|
|
|
|
# €465.54 goods (green net) + €55 world shipping = €520.54
|
|
|
|
|
|
if printf '%s' "$CA_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
|
|
|
|
|
|
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"
|
|
|
|
|
|
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"
|
|
|
|
|
|
fi
|
|
|
|
|
|
if printf '%s' "$CA_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"
|
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
|
|
# 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' \
|
|
|
|
|
|
"$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"
|
|
|
|
|
|
body_has /shop/fp6-pmos 'max="99"' "quantity input carries the technical ceiling"
|
|
|
|
|
|
|
|
|
|
|
|
# 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
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
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
|
|
|
|
|
|
# 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
|