catcrafts.net/tools/e2e.sh

777 lines
37 KiB
Shell
Raw Normal View History

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
# 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 &mdash; €569.30' "colour selector prices black"
body_has /shop/fp6-pmos 'White &mdash; €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