catcrafts.net/tools/e2e.sh
Jorijn van der Graaf a625176c7d
All checks were successful
Deploy / build-deploy (push) Successful in 7m30s
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

789 lines
38 KiB
Shell
Executable file
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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 =="
for p in / /about /shop /shop/fp6-pmos /projects /posts /demos /demos/raytracer \
/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
# 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"
# 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
# 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 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"
if extract_ld /shop/fp6-pmos | jq -e '.hasVariant[0].offers.shippingDetails | length == 3' >/dev/null 2>&1; then
ok "shipping details cover all three zones"
else
bad "shipping zones" "expected NL + EU + world tiers in the first variant's offer"
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 "~". 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
# 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 "== 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"
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 &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