tests and eurc
All checks were successful
Deploy / build-deploy (push) Successful in 4m19s

This commit is contained in:
Jorijn van der Graaf 2026-08-15 00:54:05 +02:00
commit 749f525f83
44 changed files with 5380 additions and 3532 deletions

View file

@ -31,9 +31,10 @@ jobs:
# the pixel dimensions of each mirrored file, which become the
# width/height attributes that stop the posts page reflowing as 5 MB
# recordings arrive. ffmpeg transcodes each still image into the AVIF
# and PNG renditions the page serves it between. tools/e2e.sh asserts
# both are present — so without this package the deploy fails at the
# e2e gate rather than shipping a janky page.
# and PNG renditions the page serves it between. The
# ShouldServePostPages suite asserts both are present — so without
# this package the deploy fails at the test gate rather than
# shipping a janky page.
pacman -Syu --noconfirm --needed \
nodejs \
clang lld libc++ \
@ -121,24 +122,37 @@ jobs:
dir=media
fi
tools/fetch-media.sh "$dir"
# Handed to the e2e step so it checks the files where they actually
# are. It used to assume ./media and reported every referenced file
# as missing here, which reads as a broken site and means a wrong path.
# Handed to the test step (E2E_MEDIA_DIR) so ShouldServePostPages
# checks the files where they actually are. It used to assume
# ./media and reported every referenced file as missing here, which
# reads as a broken site and means a wrong path.
echo "dir=$dir" >> "$GITHUB_OUTPUT"
- name: Build and test the backend
id: srv
# The server product builds Catcrafts.Shared for the host, which is the
# only way to actually RUN the code that generates every byte of markup
# the site emits. --selftest is a gate: if escaping or the JSON reader
# regress, the deploy stops here rather than shipping broken pages.
# the site emits. `crafter-build test` is the gate: if escaping or the
# JSON reader regress, the deploy stops here rather than shipping
# broken pages. One suite per tests/<Name>/main.cpp — the unit suites
# replaced the binary's --selftest flag, the Should{ServeRoutes,…}
# black-box suites replaced tools/e2e.sh: each spawns the freshly
# built server on its own scratch port with the fake payment rails and
# exercises it over real HTTP. ShouldBootWasmAtDepth self-skips here —
# no wasm bundle exists yet — and re-runs after the wasm build below.
#
# E2E_MEDIA_DIR points ShouldServePostPages at the mirror mount, where
# the media files actually are.
#
# Same refuse-to-guess rule as the wasm bundle below: a variant
# directory embeds a config hash, so more than one match means the tree
# is ambiguous and picking the first would deploy an arbitrary build.
env:
E2E_MEDIA_DIR: ${{ steps.media.outputs.dir }}
run: |
set -eux
crafter-build -- --product=server
crafter-build --product=server
crafter-build test --product=server
matches=$(find bin -maxdepth 1 -type d -name 'Catcrafts.Server-*' | sort)
count=$(printf '%s\n' "$matches" | grep -c . || true)
if [ "$count" -ne 1 ]; then
@ -147,7 +161,6 @@ jobs:
exit 1
fi
echo "srv=$matches" >> "$GITHUB_OUTPUT"
"$matches/catcrafts-server" --selftest
"$matches/catcrafts-server" --routes
- name: Generate sitemap and Atom feed
@ -204,26 +217,18 @@ jobs:
DIST: ${{ steps.out.outputs.dist }}
run: tools/fix-bundle-depth.sh "$DIST"
- name: End-to-end HTTP tests
# Starts the freshly built server on a scratch port and exercises it over
# real HTTP: status codes, redirects, headers, form submission, and the
# no-JavaScript guarantee. --selftest covers the pure functions; only a
# real request can show that /nope is a 404 rather than a soft 404, that
# /projects contains its content with no <script> at all, and that a
# rejected form comes back with the visitor's values still in it.
#
# Runs AFTER the wasm build, which is not cosmetic ordering. The server
# discovers the bundle under bin/ and lifts its <script> tags from it, so
# with no bundle present there is nothing to assert about wasm booting:
# "/demos/raytracer loads the wasm" fails outright, and every
# "ships no script" check passes vacuously because no page has scripts to
# begin with. Building first is what makes both meaningful.
- name: Wasm boot and script-shape checks
# The bundle-sensitive slice of the black-box suites, re-run now that
# the wasm bundle exists under bin/. The server discovers the bundle
# and lifts its <script> tags from it, so before the wasm build there
# is nothing to assert about wasm booting: ShouldBootWasmAtDepth
# self-skipped in the backend step, and ShouldStayScriptFree's checks
# around the boot tags passed vacuously because no page had scripts to
# begin with. Re-running both against the real bundle is what makes
# them meaningful.
#
# A gate, not a report: a failure here stops the deploy.
env:
SRV: ${{ steps.srv.outputs.srv }}
E2E_MEDIA_DIR: ${{ steps.media.outputs.dir }}
run: tools/e2e.sh "$SRV/catcrafts-server"
run: crafter-build test ShouldBootWasmAtDepth ShouldStayScriptFree --product=server
- name: Pre-compress static assets
# Caddy's `precompressed zstd gzip` (see deploy/Caddyfile.example)
@ -303,7 +308,7 @@ jobs:
# runner, or a polkit rule) grant CI far more than "restart one service"
# needs. Instead the last step touches a marker file that
# catcrafts-deploy.path watches — see deploy/catcrafts-deploy.path.
# That unit re-runs --selftest against the new binary before cutting
# That unit smoke-checks the new binary (--render /) before cutting
# over, so a broken deploy leaves the working server running.
env:
SRV: ${{ steps.srv.outputs.srv }}

View file

@ -103,10 +103,12 @@ authority than "restart one service" needs — the last deploy step writes
`/srv/catcrafts-app/.deploy-stamp`, and `catcrafts-deploy.path` on the host
reacts.
`catcrafts-deploy.service` runs `catcrafts-server --selftest` as `ExecStartPre`
before restarting. That self-test exercises the HTML-escaping and JSON layers
that generate every byte of markup the site emits, so **a broken binary leaves
the working server running** instead of replacing it.
`catcrafts-deploy.service` runs `catcrafts-server --render /` as `ExecStartPre`
before restarting. The unit tests already gated the deploy in CI
(`crafter-build test --product=server`); this on-host check covers what CI
cannot — the dynamic loader finding libmsquic on this machine and a page
rendering from the deployed content — so **a broken binary leaves the working
server running** instead of replacing it.
The binary is installed as `catcrafts-server.new` and `mv`d into place, so a
request arriving mid-copy never hits a truncated executable.
@ -208,7 +210,7 @@ it to `/media/<hash>` and probes the local file for dimensions, downloading
nothing. That is not just an optimisation — it removes the whole class of build
failure where the media step depends on a third party. A 167 MB recording on a
file host once hit `MAX_BYTES` (64 MB), so the entry kept its original URL, and
the deploy then failed the e2e "media origin" check on a file that was sitting on
the deploy then failed the "media origin" check (ShouldServePostPages) on a file that was sitting on
our own disk the entire time.
Adopting a `<hash>.h264.mp4` URL swaps the AV1 sibling back in as the page's
@ -243,14 +245,15 @@ Two things to know about publishing AV1:
An own-origin URL naming a file that is **not** on the mount is deliberately left
pointing at its original URL rather than rewritten. That is a post published
without its media, and failing the e2e origin check loudly beats shipping a 404
without its media, and failing the origin check (ShouldServePostPages) loudly beats shipping a 404
inside a `<video>` tag.
**`fetch-media.sh` wants `ffprobe`** (Arch: `ffmpeg`) to read pixel dimensions,
which become the `width`/`height` attributes that stop the page reflowing as
several 5 MB recordings arrive. It degrades to no dimensions without it —
so `tools/e2e.sh` asserts they are present, and a build host missing the package
fails at the e2e gate instead of quietly shipping a janky page.
so the ShouldServePostPages suite asserts they are present, and a build host
missing the package fails at the test gate instead of quietly shipping a janky
page.
**Only the post's main media is used.** Lemmy distinguishes the media a post *is*
(`post.url`, with `post.thumbnail_url` as its generated still) from images merely
@ -272,78 +275,118 @@ hotlinking would send every visitor's IP to the source instance; and these posts
are their media, so a deleted upstream file would gut the page. Filenames are the
content hash, which is why the cache lifetime can be a year.
## Payments: Mollie and CoinGate
## Payments: Mollie and EURC
Checkout offers the buyer **two choices**, each served by its own rail:
| Choice | Rail | Env var | What the buyer gets |
|----------|----------|---------------------|-----------------------------------------|
| `bank` | Mollie | `MOLLIE_API_KEY` | iDEAL, cards, bank transfer |
| `crypto` | CoinGate | `COINGATE_API_KEY` | Bitcoin + Lightning, stablecoins, more |
| `crypto` | EURC | `EURC_CHAINS` | self-hosted EURC on the configured EVM chains |
The slots are independent. Set one key and the form offers only that method;
The slots are independent. Set one and the form offers only that method;
set both and the buyer picks; set neither and checkout answers 503 with an
honest message — the whole site still works, degraded rather than down. A
mode whose key is missing is a **startup refusal**, not a silent downgrade:
a checkout that 502s at the last step is worse than one that never offered.
mode whose credential is missing is a **startup refusal**, not a silent
downgrade: a checkout that 502s at the last step is worse than one that
never offered.
### The EURC rail
No processor, no API key, no account anywhere: the buyer sends EURC (Circle's
euro stablecoin, pegged 1:1 — the token amount IS the euro total) to an
address from a pool this shop generated offline, and the server notices by
polling `balanceOf` over JSON-RPC at a finalized block. The order page is the
payment page. Three pieces of configuration:
- `EURC_CHAINS=/etc/catcrafts/eurc-chains.json` — which chains to watch.
Selecting this rail IS setting this variable. One EVM address is valid on
every chain at once, so the buyer pays on whichever is cheapest; **file
order is display order** — put the cheap chain (Base) first, and its
`note` ("lowest network fees") is the nudge the buyer sees.
```json
{"chains": [
{"name": "base", "rpc": "https://mainnet.base.org",
"contract": "0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42",
"chain_id": 8453, "note": "lowest network fees"},
{"name": "ethereum", "rpc": "https://ethereum-rpc.publicnode.com",
"contract": "0x1aBaEA1f7C830bD89Acc67eC4af516284b1bC33c",
"chain_id": 1}
]}
```
Contract addresses come from Circle's own list
(developers.circle.com/stablecoins/eurc-contract-addresses) and nowhere
else — matching the CONTRACT, not the ticker, is what makes a fake "EURC"
worthless here. EURC exists on Ethereum, Base, Avalanche, Cronos and World
Chain (not Arbitrum/Optimism/Polygon/BNB).
- `EURC_POOL` (default `<orders>.eurc-addresses`) — one receiving address
per line, `#` comments allowed. Generated by the wallet at home; the box
holds NO key, NO xpub, and can only hand out addresses it was given.
**Copy addresses, never retype**: the server cannot verify the EIP-55
checksum, so a retyped-but-still-hex address is accepted and published.
**Append only**: `<pool>.cursor` is an index into this file, so reordering
re-issues addresses already bound to old orders. Duplicates and malformed
lines are startup refusals; exhaustion too — the low-water warning fires
at 25 left.
- `EURC_WINDOW_HOURS` (default 24) — how long an order may sit unpaid. A
lapsed window is NOT bounced money: the address stays ours, a late payment
still lands there (the server logs exactly this), and it is settled by
hand with `--mark-paid`.
```sh
# Mollie keys: dashboard -> Developers -> API keys. CoinGate: dashboard ->
# API -> new app token. BOTH have real test modes that work against the real
# endpoints — verify the whole flow before swapping in production keys.
# Mollie keys: dashboard -> Developers -> API keys. Mollie's test mode works
# against the real endpoints — verify the whole flow before swapping in the
# live key. The EURC rail has no key and no test mode: point EURC_CHAINS at a
# testnet chains file (Base Sepolia / Ethereum Sepolia contracts are in
# Circle's list) to rehearse against worthless tokens instead.
install -d -m 0755 /etc/catcrafts
cat > /etc/catcrafts/payments.env <<'ENV'
MOLLIE_API_KEY=test_your-key-here
COINGATE_API_KEY=your-coingate-token
COINGATE_SANDBOX=1
EURC_CHAINS=/etc/catcrafts/eurc-chains.json
ENV
chmod 0600 /etc/catcrafts/payments.env
systemctl restart catcrafts-server
journalctl -u catcrafts-server | tail # "payments: bank=mollie crypto=coingate"
journalctl -u catcrafts-server | tail # "payments: bank=mollie crypto=eurc"
```
Drop `COINGATE_SANDBOX=1` for live crypto payments; it selects
`api-sandbox.coingate.com` and its tokens are not interchangeable with live
ones.
Mechanics worth knowing:
* The reconciler polls each open order against **the rail that issued its
link** — the ledger records `pay_choice` per order for exactly this reason.
Mollie every 10 s, CoinGate every 20 s (a blockchain confirmation will not
arrive faster), both backing off with age. The `?redirect` back from either
provider is ignored by design: only the authenticated poll moves an order
to paid, and the paid event records the method (`ideal`, `creditcard`,
`btc`, …) in the ledger.
* **Both providers EXPIRE unpaid orders**, which lapses them automatically and
the buyer just orders again. CoinGate is far more aggressive about it: two
hours before a coin is picked, twenty minutes after. Expect crypto orders to
lapse routinely; that is the normal case, not a fault.
* CoinGate settles in **EUR** (`receive_currency=EUR` in the rail), so the
money that lands is the money the invoice says, the rate is locked when the
buyer opens the invoice, and no crypto touches the balance sheet. That one
parameter is the whole difference between "a second Mollie" and "the shop
now holds crypto" — changing it is a tax decision, not a code cleanup.
Mollie every 10 s, the EURC rail every 30 s (a finalized block will not
arrive faster), both backing off with age. Arriving back on the order page
is ignored as evidence by design: only the authenticated poll (Mollie) or
the rail's own RPC check (EURC) moves an order to paid, and the paid event
records the method (`ideal`, `creditcard`, `eurc-base`, …) in the ledger.
* **Unpaid orders lapse automatically** and the buyer just orders again.
Mollie expires its own payments; the EURC rail closes its window after
`EURC_WINDOW_HOURS` (24 by default). A lapsed EURC order is NOT bounced
money — see above.
* The shop **holds EURC**: crypto revenue sits at the pool addresses until
swept, and nothing lands on the bank account until it is sold for euros at
an exchange. Bookkeeping note: a EURC sale is still a euro sale on the
invoice — EURC is euro-denominated, so there is no rate and no revaluation —
but the financials bank-callback never sees it, so crypto sales exist in
the ledger only until the sweep's SEPA leg arrives.
* Card money stays disputable for months even after "paid": before shipping a
large or exported order, glance at the `via` column in `--orders`. iDEAL,
bank transfers and crypto are final; `creditcard` is the one with a tail.
bank transfers and EURC are final; `creditcard` is the one with a tail.
This is the one real advantage of the crypto rail — no chargebacks — and it
matters most on exactly the non-EU orders where cards get declined.
* A `refunded` CoinGate order that the reconciler sees while still awaiting
gets **lapsed and logged loudly**: it means a long outage spanned the entire
paid window and the money has since gone back. That is the case where
`--mark-paid` may be the right answer and only a human can tell.
* Mollie onboarding reviews the shop: the imprint (KVK, contact address),
terms and privacy pages must be real before they approve live payments.
They are — and e2e now fails the build if a PLACEHOLDER marker ever
reaches a rendered page again. CoinGate onboarding is a KYB review of the
registered business (KVK, UBO, bank account) and wants the same pages.
They are — and ShouldStayScriptFree fails the build if a PLACEHOLDER marker ever
reaches a rendered page again. The EURC rail has no onboarding, no KYB and
no account anywhere; that is the reason it exists.
* Accepting crypto for goods does **not** make this shop a CASP under MiCA —
no custody, no transfer for third parties, so no licence is required. What
it does require is that the processor holds one: since 1 July 2026 only
MiCA-authorised CASPs may serve EU clients, and CoinGate holds both a MiCA
licence and a Payment Institution licence. Verify any replacement provider
in the ESMA register before switching a key.
no custody for others, no transfer for third parties, so no licence is
required, with or without a processor. Cashing EURC out to the bank goes
through an exchange, and THAT party must be a MiCA-authorised CASP (check
the ESMA register) — but it stands after the checkout, not in it.
* VAT is unchanged by payment method: the sale is priced and invoiced in euro
and taxed on the euro value, whichever rail settled it.
@ -398,14 +441,13 @@ Sendcloud outage keeps selling at the last known prices. Format is
(EU rates are grossed up once, at fetch, so the shop nets the carrier's cost).
That also means a hand-written cache file is a complete rate table, which is how
dev and `tools/e2e.sh` run with no Sendcloud account at all. A cache written by
dev and the black-box suites run with no Sendcloud account at all. A cache written by
an older build (flat `country -> cents`, no weight bands) is ignored on load and
replaced by the next refresh — those numbers were an unknown weight band and
re-serving them would price parcels by guess.
Like the CoinGate rail, this integration is UNTESTED against the live API until
credentials exist — the response parser is covered by `--selftest`, the fetch
around it is thin. **Verify one real fetch before opening the shop**: check that
This integration is UNTESTED against the live API until credentials exist —
the response parser is covered by the ShouldParseSendcloudRates test, the fetch around it is thin. **Verify one real fetch before opening the shop**: check that
the logged country count and the weight bands match what the Sendcloud panel
shows, because that table is now the difference between a shop that sells and
one that refuses everything.
@ -510,9 +552,10 @@ Worth knowing:
* With a signing key configured, a gpg failure means the email WAITS — an
unsigned invoice never leaves by accident, same rule as the download.
* Send a real test: `--rail=fake` locally with `MAIL_COMMAND` pointing at
msmtp and your own address in the order form, or just run `tools/e2e.sh`,
which captures the messages with a fake sendmail and verifies the attached
signature.
msmtp and your own address in the order form, or just run the
ShouldProcessCheckout suite (`crafter-build test ShouldProcessCheckout
--product=server`), which captures the messages with a fake sendmail and
verifies the attached signature.
## Reading the orders ledger
@ -560,8 +603,9 @@ A callback arrives carrying a counterparty name, an IBAN and a description.
None of it is written down. The mutation is classified, its amount is added to
a category total, its opaque id goes in a dedup ledger, and everything else is
dropped before anything touches the disk. There is no file here that could leak
a donor's identity, because no such file is ever written. `tools/e2e.sh` asserts
exactly that, by grepping the whole state directory for a test IBAN afterwards.
a donor's identity, because no such file is ever written. ShouldServeFinancialsLive
asserts exactly that, by scanning the whole state directory for a test IBAN
afterwards.
Classification is **default-deny**: money no rule claims is withheld from the
page and logged for you to write a rule for. It is never published as "other".
@ -603,7 +647,8 @@ page and logged for you to write a rule for. It is never published as "other".
`group` is `donations`, `expense` or `ignore`; first match wins,
and explicit rules beat the donation-account default (which is how your own
transfer between accounts stays out of the donation total). **Ignore your
Mollie and CoinGate payouts** — those are sales, already counted from the
Mollie payouts, and one day the exchange's SEPA leg from selling EURC** —
those are sales, already counted from the
ledger, and letting them through would publish that money twice. A rule with
no criterion, an unknown group, or an expense with no label is dropped at
load rather than allowed to claim everything.
@ -757,11 +802,12 @@ split working as designed.
tools/fetch-posts.sh # pull posts from the allowed communities
tools/fetch-media.sh [dir] # mirror their media locally (run after the above)
tools/fetch-rates.sh # ECB reference rates for the indicative prices
tools/e2e.sh # ~200 HTTP checks against a real server (135 while
# coming-soon; the rest re-arm at launch); the CI gate
crafter-build --local -r # the wasm app alone, no backend, on :8080
<server>/catcrafts-server --selftest # ~260 in-process assertions
crafter-build test --product=server --local # every suite: the unit assertions
# AND the black-box HTTP suites, which spawn the
# real server on scratch ports (the CI gate; the
# checkout lifecycle re-arms at launch)
<server>/catcrafts-server --routes # status + title for every route
<server>/catcrafts-server --render /projects # dump one page's HTML
<server>/catcrafts-server --orders FILE # the orders ledger + manual transitions

View file

@ -11,10 +11,20 @@ Documentation=https://forgejo.catcrafts.net/Catcrafts/catcrafts.net
[Service]
Type=oneshot
# Verify the freshly deployed binary before cutting over. --selftest exercises
# the escaping and JSON layers that produce every byte of markup the site
# emits; if it fails, the currently-running (working) server is left alone
# rather than replaced with a broken one.
ExecStartPre=/srv/catcrafts-app/catcrafts-server --selftest
# Verify the freshly deployed binary before cutting over. The unit tests run
# in CI (crafter-build test) before anything is shipped, so what this gate
# checks is the part CI cannot: that THIS binary executes on THIS host — the
# dynamic loader finds libmsquic (the first real deploy died exactly there,
# exit 127) — and renders a page from the deployed content. If it fails, the
# currently-running (working) server is left alone rather than replaced with
# a broken one.
#
# WorkingDirectory because --render loads ./content; StandardOutput=null so
# the rendered HTML does not land in the journal on every deploy (stderr,
# where failures speak, still does).
WorkingDirectory=/srv/catcrafts-app
StandardOutput=null
StandardError=journal
ExecStartPre=/srv/catcrafts-app/catcrafts-server --render /
ExecStart=/usr/bin/systemctl restart catcrafts-server.service

View file

@ -76,8 +76,10 @@ ReadOnlyPaths=/srv/catcrafts-app /srv/catcrafts.net
# rsync-wiped, and /srv/catcrafts-app is CI-writable; neither may ever hold a
# credential. /etc/catcrafts/payments.env (root:root 0600) carries:
# MOLLIE_API_KEY=live_... (or test_... while verifying) — the BANK rail
# COINGATE_API_KEY=... the CRYPTO rail; omit and checkout offers only
# COINGATE_SANDBOX=1 bank. Sandbox tokens are not live tokens.
# EURC_CHAINS=/etc/catcrafts/eurc-chains.json — the CRYPTO rail (self-hosted
# EURC; no key). Omit and checkout offers only bank.
# The address pool defaults to
# /var/lib/catcrafts/orders.jsonl.eurc-addresses.
# SENDCLOUD_PUBLIC_KEY / SENDCLOUD_SECRET_KEY / SENDCLOUD_METHOD — REQUIRED
# to sell: no rate table means checkout refuses
# INVOICE_GPG_KEY=... invoice signing (see deploy/README.md)

View file

@ -20,12 +20,25 @@ using namespace Crafter;
// server — the native HTTP server that will render the same pages
// server-side for crawlers and no-JS clients, and later
// host the shop API. No Crafter.Graphics.
// crafter-build -- --product=server
// crafter-build --product=server
//
// One project file rather than three, following the imsd convention: a single
// CrafterBuildProject returns a single Configuration, so the products are
// branches and the shared library is a `static unique_ptr<Configuration>`
// both branches can depend on.
//
// The tests hang off the server product (tests/<Name>/main.cpp, the
// Crafter.Network layout):
//
// crafter-build test --product=server
//
// Two layers, both under the same runner. The unit suites replaced the
// --selftest flag the server binary used to carry: same assertions, but each
// suite is its own binary with the framework's parallel runner, timeouts and
// crash detection instead of one grab-bag function. The black-box suites
// (ShouldServeRoutes and friends) replaced tools/e2e.sh: each spawns the
// freshly built binary on its own scratch port — via tests/harness/ — and
// asserts over real HTTP the things only a real request can show.
// Catcrafts.Shared — target-neutral page renderers, built as a static library
// for whichever target the selected product is using. `static` because the
@ -39,6 +52,14 @@ using namespace Crafter;
// .cpp lands here that would be a wrong-architecture link.
static Configuration* SharedLibrary(std::span<const std::string_view> args) {
static auto shared = std::make_unique<Configuration>();
// Idempotent, and it MUST be: GetInterfacesAndImplementations may run only
// once — a second call re-parses the same .cppm files into duplicate
// Module entries, and the duplicated units then compile concurrently into
// the same PCM paths, failing the build with "module not found" at
// whichever partition lost the race. The server branch reaches here twice
// (once via ServerCore, once directly), which is what made this real.
// Only one product branch runs per process, so first-call args win.
if (!shared->name.empty()) return shared.get();
shared->path = "./";
shared->name = "Catcrafts.Shared";
shared->outputName = "Catcrafts.Shared";
@ -63,6 +84,53 @@ static Configuration* SharedLibrary(std::span<const std::string_view> args) {
return shared.get();
}
// Catcrafts.ServerCore — every implementation unit of the server except
// main.cpp, as a static library. The split exists for the tests: a test can
// only LINK a library-type dependency (crafter-build exports -L/-l for
// libraries alone), and the Mollie/EURC/Sendcloud parsers, the invoice
// builder and the bunq classifier all live here. The exe is main.cpp plus
// this library, so the split costs nothing at deploy time.
//
// Same `static unique_ptr` convention as SharedLibrary, and the same warning:
// cfg.dependencies holds a raw pointer, so the Configuration must outlive
// CrafterBuildProject.
static Configuration* ServerCore(std::span<const std::string_view> args, Configuration* network) {
static auto core = std::make_unique<Configuration>();
// Same idempotence rule as SharedLibrary — see the warning there.
if (!core->name.empty()) return core.get();
core->path = "./";
core->name = "Catcrafts.ServerCore";
core->outputName = "Catcrafts.ServerCore";
ApplyStandardArgs(*core, args);
core->type = ConfigurationType::LibraryStatic;
core->dependencies = { SharedLibrary(args), network };
std::array<fs::path, 1> ifaces = {
"server/interfaces/Catcrafts.Server",
};
std::array<fs::path, 8> impls = {
"server/implementations/Catcrafts.Server-Http",
"server/implementations/Catcrafts.Server-Orders",
"server/implementations/Catcrafts.Server-Mollie",
"server/implementations/Catcrafts.Server-Invoice",
"server/implementations/Catcrafts.Server-Eurc",
"server/implementations/Catcrafts.Server-Shipping",
"server/implementations/Catcrafts.Server-Mail",
"server/implementations/Catcrafts.Server-Financials",
};
core->GetInterfacesAndImplementations(ifaces, impls);
// Both rails reach their provider over TLS, which is what libssl is for
// here. libcrypto is named again on its own account: the bunq mutation
// callback verifies an RSA-SHA256 body signature through EVP, so this
// library calls libcrypto directly rather than only inheriting it as
// libssl's dependency. On the lib rather than the exe because link flags
// propagate to consumers — the exe and every test get them from here.
core->linkFlags.push_back("-lssl");
core->linkFlags.push_back("-lcrypto");
return core.get();
}
// Payload trimming, release only.
//
// The wasi-libc / wasi-libc++ static libs ship with DWARF and wasm-ld keeps
@ -108,38 +176,23 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
.args = netArgs,
});
Configuration* core = ServerCore(args, network);
Configuration* shared = SharedLibrary(args);
Configuration cfg;
cfg.path = "./";
cfg.name = "Catcrafts.Server";
cfg.outputName = "catcrafts-server";
cfg.type = ConfigurationType::Executable;
ApplyStandardArgs(cfg, args);
cfg.dependencies = { SharedLibrary(args), network };
cfg.dependencies = { core, shared, network };
std::array<fs::path, 1> ifaces = {
"server/interfaces/Catcrafts.Server",
};
std::array<fs::path, 9> impls = {
std::array<fs::path, 0> ifaces = {};
std::array<fs::path, 1> impls = {
"server/implementations/main",
"server/implementations/Catcrafts.Server-Http",
"server/implementations/Catcrafts.Server-Orders",
"server/implementations/Catcrafts.Server-Mollie",
"server/implementations/Catcrafts.Server-Invoice",
"server/implementations/Catcrafts.Server-Coingate",
"server/implementations/Catcrafts.Server-Shipping",
"server/implementations/Catcrafts.Server-Mail",
"server/implementations/Catcrafts.Server-Financials",
};
cfg.GetInterfacesAndImplementations(ifaces, impls);
// Both rails reach their provider over TLS, which is what libssl is
// for here. libcrypto is named again on its own account: the bunq
// mutation callback verifies an RSA-SHA256 body signature through
// EVP, so this product calls libcrypto directly rather than only
// inheriting it as libssl's dependency.
cfg.linkFlags.push_back("-lssl");
cfg.linkFlags.push_back("-lcrypto");
// libmsquic.so.2 is built in crafter-build's external cache, and the
// RUNPATH pointing there only exists on the build machine — the first
// deploy to a real host died in the loader (exit 127) because of it.
@ -152,6 +205,69 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
cfg.linkFlags.push_back("-Wl,-rpath,/srv/catcrafts-app");
ApplyReleaseTrimming(cfg);
// ── tests ─────────────────────────────────────────────────────
// crafter-build test --product=server
//
// Each suite is tests/<Name>/main.cpp. The first group tests
// Catcrafts.Shared alone — the escaping/JSON/form/money layers that
// produce every byte of markup the site emits. The second group also
// links the server core: provider payload parsers, the invoice and
// email builders, and the financials ingest.
cfg.AddTest("ShouldEscapeHtml").Dependencies({ shared });
cfg.AddTest("ShouldParseJson").Dependencies({ shared });
cfg.AddTest("ShouldValidateForms").Dependencies({ shared });
cfg.AddTest("ShouldComputeMoney").Dependencies({ shared });
cfg.AddTest("ShouldShipContent").Dependencies({ shared });
cfg.AddTest("ShouldBuildMediaLadders").Dependencies({ shared });
cfg.AddTest("ShouldRenderMarkdown").Dependencies({ shared });
cfg.AddTest("ShouldServePosts").Dependencies({ shared });
cfg.AddTest("ShouldParseSendcloudRates").Dependencies({ core, shared });
cfg.AddTest("ShouldMintOrderTokens").Dependencies({ core, shared });
cfg.AddTest("ShouldParseMolliePayments").Dependencies({ core, shared });
cfg.AddTest("ShouldParseEurcChains").Dependencies({ core, shared });
cfg.AddTest("ShouldGuardRequestProvenance").Dependencies({ core, shared });
cfg.AddTest("ShouldBuildInvoices").Dependencies({ core, shared });
cfg.AddTest("ShouldPublishFinancials").Dependencies({ core, shared });
// ── black-box suites (the tools/e2e.sh port) ──────────────────
// Each spawns the REAL binary — depending on &cfg is what builds it
// first (an Executable dep is built and mtime-tracked, it just links
// nothing) — on its OWN port: the suites run in parallel, so a shared
// port would race. The harness module is compiled into each suite via
// the interfaces overload; Args hands each binary the exact path to
// the exe this configuration produces, so a stale sibling variant
// under bin/ can never be the thing under test.
//
// BinDir() must be read AFTER ApplyReleaseTrimming: compileFlags are
// part of the variant hash, so reading it earlier would name a
// directory the build never writes.
std::array<fs::path, 1> harness = { "tests/harness/Catcrafts.E2eHarness" };
const std::string serverBin = (cfg.BinDir() / cfg.outputName).string();
cfg.AddTest("ShouldServeRoutes", harness)
.Dependencies({ &cfg, shared, network }).Args({ serverBin });
cfg.AddTest("ShouldStayScriptFree", harness)
.Dependencies({ &cfg, shared, network }).Args({ serverBin });
cfg.AddTest("ShouldEmitStructuredData", harness)
.Dependencies({ &cfg, shared, network }).Args({ serverBin });
cfg.AddTest("ShouldBootWasmAtDepth", harness)
.Dependencies({ &cfg, shared, network }).Args({ serverBin });
cfg.AddTest("ShouldServePostPages", harness)
.Dependencies({ &cfg, shared, network }).Args({ serverBin });
cfg.AddTest("ShouldServeFinancialsLive", harness)
.Dependencies({ &cfg, shared, network }).Args({ serverBin });
cfg.AddTest("ShouldSellTheShopFront", harness)
.Dependencies({ &cfg, shared, network }).Args({ serverBin });
// The full order lifecycle signs real invoices (ephemeral key) and
// waits out the reconciler and mailer cadences, so it both needs gpg
// and deserves more than the 60 s default.
cfg.AddTest("ShouldProcessCheckout", harness)
.Dependencies({ &cfg, shared, network }).Args({ serverBin })
.Requires("tool:gpg")
.Timeout(std::chrono::seconds(180));
return cfg;
}

View file

@ -1,286 +0,0 @@
/*
catcrafts.net
Copyright (C) 2026 Catcrafts
The source code of this website is made available for viewing purposes only.
No permission is granted to copy, modify, distribute, or create derivative works.
*/
// The CoinGate payment rail — the crypto half of the checkout.
//
// Why a processor rather than a self-hosted node: accepting crypto for goods
// makes this shop a MERCHANT, not a crypto-asset service provider, under either
// arrangement. What differs is everything around it. CoinGate is MiCA-licensed
// (mandatory to serve EU clients since 1 July 2026) and settles EUR to the
// business account by SEPA at a locked rate, so the money that lands is the
// money the invoice says, the bookkeeping line is identical to Mollie's, and no
// coin ever sits on this balance sheet waiting to move in price. A self-hosted
// BTCPay would cost 1% less and a Bitcoin node's worth of operations, custody
// and per-payment revaluation — a trade worth making for sovereignty, not for a
// tail of international orders.
//
// Which is why receive_currency is EUR below and not DO_NOT_CONVERT: that one
// parameter is the whole difference between "a second Mollie" and "the shop now
// holds crypto". Changing it is a tax decision, not a code cleanup.
//
// The API is the same shape as Mollie's, so this client is the same shape as
// that one:
//
// POST /api/v2/orders {price_amount, price_currency, …} -> id + payment_url
// GET /api/v2/orders/{id} -> status, pay_currency
//
// Two differences from Mollie worth knowing. Requests are form-encoded, which
// is what every CoinGate example uses and what their v2 API is documented
// against — the responses are JSON either way, and JSON is the only direction
// that matters here, since it is the one carrying money. And their ids are JSON
// NUMBERS, not strings, so the parser renders them to decimal (see
// ParseCoingateOrder) and the ledger stores text like it does for every rail.
//
// Trust direction is the design rule and is unchanged: CoinGate can be told a
// callback_url and it is deliberately NOT given one. An order becomes paid only
// when an authenticated GET says status=paid over a covering EUR amount.
// Crypto invoices die fast — two hours before a coin is picked, twenty minutes
// after — so Dead is a state this rail reaches far more often than Mollie does,
// and the reconciler lapsing those orders is the normal case rather than an
// exception.
module;
module Catcrafts.Server;
import std;
import Catcrafts.Shared;
import Crafter.Network;
using namespace Crafter;
namespace Catcrafts::Server {
namespace {
// Percent-encode one form value. Unreserved characters pass; everything else
// becomes %XX, including the space (rather than '+', which is only correct in
// a query string and is one of those differences that works until it doesn't).
std::string FormEncode(std::string_view s) {
static constexpr std::string_view kHex = "0123456789ABCDEF";
std::string out;
out.reserve(s.size() + 8);
for (const char c : s) {
const bool unreserved = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z')
|| (c >= '0' && c <= '9')
|| c == '-' || c == '_' || c == '.' || c == '~';
if (unreserved) {
out += c;
} else {
const auto byte = static_cast<unsigned char>(c);
out += '%';
out += kHex[byte >> 4];
out += kHex[byte & 0x0f];
}
}
return out;
}
// Ticker symbols arrive uppercase ("BTC"); the ledger's via column is lowercase
// everywhere else ("ideal", "creditcard"), and a column that shouts in one row
// and whispers in the next is just noise to read past.
std::string LowerAscii(std::string_view s) {
std::string out(s);
for (char& c : out) {
if (c >= 'A' && c <= 'Z') c = static_cast<char>(c - 'A' + 'a');
}
return out;
}
// CoinGate caps title at 150 characters and description at 500. Both are built
// from the order reference here and come nowhere near either, but a truncating
// helper means a future longer description degrades to a shorter one rather
// than to a 422 at checkout — with the buyer already committed.
std::string Clamp(std::string_view s, std::size_t max) {
return std::string(s.substr(0, std::min(s.size(), max)));
}
} // namespace
std::optional<CoingateOrder> ParseCoingateOrder(std::string_view json) {
auto doc = Json::Parse(json);
if (!doc || !doc->IsObject()) return std::nullopt;
CoingateOrder o;
// The id arrives as a number. Accept a string too: costing a live checkout
// over a provider changing a field's JSON type would be an absurd way to
// lose a sale, and either spelling names the same order.
if (const Json::Value* id = doc->Find("id")) {
if (id->type == Json::Type::String) {
o.id = id->string;
} else if (id->type == Json::Type::Number) {
o.id = std::format("{}", static_cast<std::int64_t>(id->number));
}
}
o.status = std::string(doc->Str("status"));
o.payCurrency = std::string(doc->Str("pay_currency"));
o.payUrl = std::string(doc->Str("payment_url"));
if (o.id.empty() || o.status.empty()) return std::nullopt;
// Only euro-priced orders are ever created, so anything else failing to
// parse to zero is the safe outcome — a zero amount never satisfies an
// order total. Note this is price_amount (what the buyer owed) and not
// receive_amount (what lands after conversion and fee): the question being
// asked is whether the buyer paid their invoice, not what the shop nets.
if (doc->Str("price_currency") == "EUR") {
if (auto minor = ParseAmountToMinor(doc->Str("price_amount"))) {
o.priceMinor = *minor;
}
}
return o;
}
namespace {
class CoingateRail final : public PaymentRail {
public:
explicit CoingateRail(RailConfig cfg)
: cfg_(std::move(cfg)),
host_(cfg_.sandbox ? "api-sandbox.coingate.com" : "api.coingate.com") {}
std::optional<PaymentLink> CreateLink(std::int64_t amountMinor,
const std::string& description,
const std::string& redirectUrl) override {
std::lock_guard lock(mutex_);
// receive_currency=EUR is the settlement decision; see the header.
// No callback_url on purpose: state comes from the poll, never from
// something that arrives unbidden claiming an order was paid.
const std::string body = std::format(
"price_amount={}&price_currency=EUR&receive_currency=EUR"
"&title={}&description={}&order_id={}&success_url={}&cancel_url={}",
FormEncode(Money::FormatMinor(amountMinor)),
FormEncode(Clamp(description, 150)),
FormEncode(Clamp(description, 500)),
FormEncode(Clamp(description, 255)),
FormEncode(redirectUrl), FormEncode(redirectUrl));
const std::optional<std::string> res = Call("POST", "/api/v2/orders", body);
if (!res) return std::nullopt;
const auto order = ParseCoingateOrder(*res);
if (!order || order->payUrl.empty()) {
std::println(std::cerr, "coingate: create returned no payment url");
return std::nullopt;
}
PaymentLink link;
link.payId = order->id;
link.payUrl = order->payUrl;
return link;
}
std::optional<PaidStatus> CheckPaid(const std::string& payId,
std::int64_t expectedMinor) override {
std::lock_guard lock(mutex_);
// CoinGate ids are decimal integers. The id came from them, but it
// travels through our ledger — keep the path composition strict anyway.
if (payId.empty()) return PaidStatus{ PayState::Dead, {} };
for (const char c : payId) {
if (c < '0' || c > '9') return PaidStatus{ PayState::Dead, {} };
}
const std::optional<std::string> res =
Call("GET", "/api/v2/orders/" + payId, {});
if (!res) return std::nullopt;
const auto order = ParseCoingateOrder(*res);
if (!order) return std::nullopt;
PaidStatus out;
// What settled it, for the ledger's "via" column: the coin the shopper
// actually paid in ("BTC" -> "bitcoin" is their business, not ours —
// the ticker is the honest record). Empty until a coin is picked.
out.method = order->payCurrency.empty()
? std::string("crypto")
: LowerAscii(order->payCurrency);
const std::string_view status = order->status;
if (status == "paid" && order->priceMinor >= expectedMinor) {
out.state = PayState::Paid;
} else if (status == "new" || status == "pending" || status == "confirming") {
// Still in flight. "confirming" is the blockchain-confirmation
// wait: the money is visible but not final, and this rail does not
// treat visible as received.
out.state = PayState::Pending;
} else if (status == "refunded" || status == "partially_refunded") {
// Paid and then given back — which, seen from an order still
// awaiting payment, means the reconciler missed the entire paid
// window (a long outage) and the money has since left again. Lapse
// it rather than confirm an order whose payment was undone, and say
// so loudly: this is the one case where --mark-paid may be the
// right answer and only a human can tell.
std::println(std::cerr,
"coingate: order {} is {} — lapsing; confirm by hand if "
"the refund was partial and the goods still ship",
payId, status);
out.state = PayState::Dead;
} else if (status == "invalid" || status == "expired" || status == "canceled") {
out.state = PayState::Dead;
} else {
// An unknown status is not a licence to guess. Pending means "ask
// again", which is the only safe reading of a word we do not know.
std::println(std::cerr, "coingate: order {} has unknown status '{}'",
payId, status);
out.state = PayState::Pending;
}
return out;
}
std::string_view Name() const override { return "coingate"; }
// Slower than Mollie's ten seconds: a crypto payment waits on block
// confirmations, so there is nothing a faster sweep could learn. The
// buyer's own arrival at the order page still polls once immediately.
std::chrono::seconds PollInterval() const override { return std::chrono::seconds(20); }
private:
// One HTTPS call; nullopt on transport failure or a non-2xx answer. The
// reconciler treats nullopt as "unknown, retry" — never as unpaid or dead.
std::optional<std::string> Call(std::string_view method, const std::string& path,
const std::string& body) {
try {
if (!client_) {
client_ = std::make_unique<Crafter::ClientHTTP1>(
host_, static_cast<std::uint16_t>(443),
Crafter::TLSClientCredentials{});
}
Crafter::HTTPRequest req;
req.method = std::string(method);
req.path = path;
req.authority = host_;
req.body = body;
// Not "Bearer": CoinGate's scheme word is literally "Token".
req.headers["authorization"] = "Token " + cfg_.apiKey;
req.headers["user-agent"] = "catcrafts.net-server/1.0 (+https://catcrafts.net)";
req.headers["accept"] = "application/json";
if (!body.empty()) {
req.headers["content-type"] = "application/x-www-form-urlencoded";
}
const Crafter::HTTPResponse res = client_->Send(req);
if (res.status.size() != 3 || res.status[0] != '2') {
std::println(std::cerr, "coingate: {} {} -> {} {}", method, path,
res.status, res.body.substr(0, 200));
return std::nullopt;
}
return res.body;
} catch (const std::exception& e) {
std::println(std::cerr, "coingate: {} {} failed: {}", method, path, e.what());
client_.reset(); // dial fresh next time
return std::nullopt;
}
}
RailConfig cfg_;
std::string host_;
std::mutex mutex_;
std::unique_ptr<Crafter::ClientHTTP1> client_;
};
} // namespace
std::unique_ptr<PaymentRail> MakeCoingateRail(const RailConfig& config) {
return std::make_unique<CoingateRail>(config);
}
} // namespace Catcrafts::Server

View file

@ -0,0 +1,673 @@
/*
catcrafts.net
Copyright (C) 2026 Catcrafts
The source code of this website is made available for viewing purposes only.
No permission is granted to copy, modify, distribute, or create derivative works.
*/
// The EURC payment rail — the crypto half of the checkout, with no processor.
//
// Accepting crypto for goods makes this shop a MERCHANT and not a crypto-asset
// service provider, with or without a processor in between, so the licence was
// never the question; what a processor would buy is EUR settlement, and what it
// would cost is a KYB gate standing between the shop and its own checkout.
// Here nobody sits in the payment path at all: the buyer sends EURC to an
// address this shop already owns, and the server's only role is to notice.
//
// Why EURC and not a coin: EURC is euro-denominated at par, so there is no rate
// to quote, no quote to expire, no revaluation at year end, and no exchange-rate
// line in the books. €563.30 owed is 563300000 EURC base units owed, forever.
// That collapses the entire pricing problem to integer arithmetic, which is the
// same arithmetic every other amount in this codebase already uses.
//
// Why an address POOL and not xpub derivation. Deriving addresses on demand
// would need BIP32, secp256k1 and Keccak-256 in this process, and would put an
// extended public key on the internet-facing box. A pool needs none of it: the
// addresses are generated once, offline, by the wallet that holds the keys, and
// arrive here as a plain list. This process can therefore only ever LEARN an
// address it was given — it cannot derive the next one, cannot recognise a
// sibling, and has nothing on disk that is worth stealing. It is the same rule
// the bunq key follows, taken one step further.
//
// Why balanceOf and not log scanning. One eth_call answers "how much EURC does
// this address hold", which is the entire question. Asking it AT A FINALIZED
// BLOCK makes reorg handling somebody else's problem rather than a confirmation
// counter this code would have to get right. The function selector is the first
// four bytes of keccak256("balanceOf(address)") — a constant since 2015, spelled
// out below, which is why no Keccak implementation is needed here either.
//
// Why one address covers several chains. An EVM address is derived from a public
// key and is not chain-specific, so the SAME address is valid on Ethereum, Base
// and Avalanche at once. One assignment therefore covers every chain we watch,
// the buyer pays on whichever is cheapest for them, and the classic "sent it on
// the wrong network" support ticket becomes a payment we were watching for
// anyway. The chain that settles it is recorded as the ledger's via column
// ("eurc-base"), because which chain the money arrived on is a fact worth
// keeping.
//
// Trust direction is unchanged and, for once, trivially so: there is no provider
// to send a callback, so there is nothing to ignore. An order becomes paid when
// an RPC we chose to call reports a covering balance at a finalized block.
//
// ONE HAZARD A PROCESSOR WOULD NOT HAVE, stated plainly because it will
// eventually happen: Dead here does NOT mean the money bounced. A processor's
// invoice that expires is dead in the sense that no money can arrive against it.
// An address is ours forever, so a buyer who pays after the window still sends
// real EURC to a real address we control. The order lapses; the money arrives
// regardless. That is why lapsing logs the address rather than dropping it, why
// the address stays bound to the order in the ledger, and why the window
// defaults to a generous 24 hours instead of a processor's twenty minutes —
// there is no cost to waiting when the destination is our own wallet.
module;
module Catcrafts.Server;
import std;
import Catcrafts.Shared;
import Crafter.Network;
using namespace Crafter;
namespace Catcrafts::Server {
namespace {
// keccak256("balanceOf(address)")[0..4). A constant of the ERC-20 ABI, not a
// value we compute — which is the whole reason this unit needs no Keccak.
constexpr std::string_view kBalanceOfSelector = "0x70a08231";
// EURC carries 6 decimals on every chain Circle deploys it to. Amounts in this
// codebase are EUR cents (2 decimals), so a covering balance is
// cents * 10^(decimals-2). Kept per chain anyway: a future token with a
// different scale should be a config line, not a patch.
constexpr int kDefaultDecimals = 6;
bool IsHexDigit(char c) {
return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F');
}
std::string LowerAscii(std::string_view s) {
std::string out(s);
for (char& c : out) {
if (c >= 'A' && c <= 'Z') c = static_cast<char>(c - 'A' + 'a');
}
return out;
}
// "0x" followed by exactly 40 hex digits. Deliberately NOT an EIP-55 checksum
// check: verifying the mixed-case checksum would need Keccak, which this unit
// does not carry. The consequence is operational and is documented at the pool
// loader — addresses must be COPIED from the wallet that generated them, never
// retyped, because a typo that stays hex will not be caught here.
bool IsAddress(std::string_view s) {
if (s.size() != 42) return false;
if (s[0] != '0' || (s[1] != 'x' && s[1] != 'X')) return false;
for (std::size_t i = 2; i < s.size(); ++i) {
if (!IsHexDigit(s[i])) return false;
}
return true;
}
// Split an RPC endpoint into the pieces Crafter::ClientHTTP1 wants. Plain http
// is accepted so a node on the home LAN can be used later without a certificate;
// anything else is a configuration error rather than a silent default.
struct Endpoint {
std::string host;
std::string path = "/";
std::uint16_t port = 443;
bool tls = true;
};
std::optional<Endpoint> ParseEndpoint(std::string_view url) {
Endpoint ep;
if (url.starts_with("https://")) {
url.remove_prefix(8);
} else if (url.starts_with("http://")) {
ep.tls = false;
ep.port = 80;
url.remove_prefix(7);
} else {
return std::nullopt;
}
if (url.empty()) return std::nullopt;
const std::size_t slash = url.find('/');
std::string_view authority = slash == std::string_view::npos ? url : url.substr(0, slash);
if (slash != std::string_view::npos) ep.path = std::string(url.substr(slash));
if (authority.empty()) return std::nullopt;
// A colon here is a port, not IPv6-in-a-URL: those are bracketed, and an
// RPC endpoint spelled with a bare IPv6 literal is not a case worth
// guessing at.
if (const std::size_t colon = authority.rfind(':'); colon != std::string_view::npos) {
std::uint32_t parsed = 0;
const std::string_view digits = authority.substr(colon + 1);
const auto [ptr, ec] =
std::from_chars(digits.data(), digits.data() + digits.size(), parsed);
if (ec != std::errc{} || ptr != digits.data() + digits.size() || parsed == 0
|| parsed > 65535) {
return std::nullopt;
}
ep.port = static_cast<std::uint16_t>(parsed);
authority = authority.substr(0, colon);
}
if (authority.empty()) return std::nullopt;
ep.host = std::string(authority);
return ep;
}
// 10^n as an integer, saturating rather than wrapping. n is small and config-
// bounded, but this is money arithmetic and a silent wrap is the wrong failure.
std::optional<std::int64_t> Pow10(int n) {
if (n < 0 || n > 18) return std::nullopt;
std::int64_t out = 1;
for (int i = 0; i < n; ++i) out *= 10;
return out;
}
} // namespace
// A 32-byte uint256 hex word, as eth_call returns it, reduced to an int64.
// SATURATES rather than wraps: a balance larger than int64 can hold is still
// unambiguously "covers any invoice this shop will ever issue", and saturating
// there keeps every comparison downstream in ordinary signed arithmetic.
// Exported so the self-test can drive it with canned RPC bodies, the same way
// ParseMolliePayment is driven — the HTTP around it is thin, the decoding is
// where a mistake would cost money.
std::optional<std::int64_t> ParseEthCallUint(std::string_view json) {
auto doc = Json::Parse(json);
if (!doc || !doc->IsObject()) return std::nullopt;
// A JSON-RPC error is a real answer and must not read as a zero balance:
// "the node refused" and "the buyer has not paid" are different facts and
// only one of them should ever lapse an order.
if (const Json::Value* err = doc->Find("error"); err && err->type != Json::Type::Null) {
return std::nullopt;
}
const Json::Value* res = doc->Find("result");
if (!res || res->type != Json::Type::String) return std::nullopt;
std::string_view hex = res->string;
if (!hex.starts_with("0x") && !hex.starts_with("0X")) return std::nullopt;
hex.remove_prefix(2);
if (hex.empty() || hex.size() > 64) return std::nullopt;
std::int64_t out = 0;
for (const char c : hex) {
if (!IsHexDigit(c)) return std::nullopt;
int digit = 0;
if (c >= '0' && c <= '9') digit = c - '0';
else if (c >= 'a' && c <= 'f') digit = c - 'a' + 10;
else digit = c - 'A' + 10;
// Saturate on overflow instead of wrapping.
if (out > (std::numeric_limits<std::int64_t>::max() - digit) / 16) {
return std::numeric_limits<std::int64_t>::max();
}
out = out * 16 + digit;
}
return out;
}
// Parse the chains file. Refuses partial success on purpose: a chain list where
// one entry silently dropped is a shop that quietly stops noticing payments on
// that chain, which is indistinguishable from a buyer who never paid.
std::optional<std::vector<EurcChain>> ParseEurcChains(std::string_view json) {
auto doc = Json::Parse(json);
if (!doc || !doc->IsObject()) return std::nullopt;
const Json::Value* arr = doc->Find("chains");
if (!arr || !arr->IsArray()) return std::nullopt;
std::vector<EurcChain> out;
for (const Json::Value& v : arr->array) {
if (!v.IsObject()) return std::nullopt;
EurcChain c;
c.name = std::string(v.Str("name"));
c.rpcUrl = std::string(v.Str("rpc"));
c.contract = LowerAscii(v.Str("contract"));
c.blockTag = std::string(v.Str("block_tag", "finalized"));
if (const Json::Value* d = v.Find("decimals"); d && d->type == Json::Type::Number) {
c.decimals = static_cast<int>(d->number);
}
if (const Json::Value* d = v.Find("chain_id"); d && d->type == Json::Type::Number) {
c.chainId = static_cast<std::int64_t>(d->number);
}
c.note = std::string(v.Str("note"));
if (c.chainId < 0) return std::nullopt;
if (c.name.empty() || c.rpcUrl.empty()) return std::nullopt;
if (!IsAddress(c.contract)) return std::nullopt;
if (!ParseEndpoint(c.rpcUrl)) return std::nullopt;
// 2 is the floor because amounts arrive as cents; anything below it
// cannot represent the invoice at all.
if (c.decimals < 2 || c.decimals > 18) return std::nullopt;
// "latest" is accepted but is a foot-gun worth naming: it reports state
// that a reorg can still take back.
if (c.blockTag == "latest") {
std::println(std::cerr,
"eurc: chain '{}' watches block_tag=latest — a reorg can "
"un-pay a settled order; prefer 'finalized'", c.name);
}
out.push_back(std::move(c));
}
if (out.empty()) return std::nullopt;
return out;
}
namespace {
// The two halves of this rail's payId ("<address>@<unix-deadline>"), or
// nullopt for anything that does not parse — which CheckPaid reads as Dead
// (the id came from us; a mangled one identifies no payment) and Instructions
// reads as "nothing to render".
struct PayIdParts {
std::string address;
std::int64_t deadline = 0;
};
std::optional<PayIdParts> SplitPayId(std::string_view payId) {
const auto at = payId.rfind('@');
if (at == std::string_view::npos) return std::nullopt;
PayIdParts parts;
parts.address = LowerAscii(payId.substr(0, at));
if (!IsAddress(parts.address)) return std::nullopt;
const std::string_view digits = payId.substr(at + 1);
const auto [ptr, ec] =
std::from_chars(digits.data(), digits.data() + digits.size(), parts.deadline);
if (ec != std::errc{} || ptr != digits.data() + digits.size()) return std::nullopt;
return parts;
}
class EurcRail final : public PaymentRail {
public:
explicit EurcRail(RailConfig cfg) : cfg_(std::move(cfg)) {}
// Loading is separate from construction so a bad pool or chain file is a
// startup refusal with a reason, not a rail that constructs fine and then
// fails at the one moment a buyer is committed.
bool Load() {
if (!LoadChains()) return false;
if (!LoadPool()) return false;
cursor_ = ReadCursor();
if (cursor_ >= pool_.size()) {
std::println(std::cerr,
"eurc: address pool is exhausted ({} of {} used) — top it "
"up from the wallet before enabling the crypto rail",
cursor_, pool_.size());
return false;
}
const std::size_t left = pool_.size() - cursor_;
std::println(std::cerr, "eurc: {} chains, {} addresses left of {}",
chains_.size(), left, pool_.size());
if (left < kLowWaterMark) {
std::println(std::cerr,
"eurc: WARNING only {} addresses left — top up the pool", left);
}
return true;
}
std::optional<PaymentLink> CreateLink(std::int64_t amountMinor,
const std::string& description,
const std::string& redirectUrl) override {
std::lock_guard lock(mutex_);
(void)description; // nothing off-box to label; the ledger holds it
if (amountMinor <= 0) return std::nullopt;
if (cursor_ >= pool_.size()) {
std::println(std::cerr,
"eurc: refusing checkout — address pool exhausted");
return std::nullopt;
}
// Burn the address BEFORE handing it out. A crash between these two
// points wastes one address; the opposite order would hand the same
// address to two orders, and the second buyer's payment would appear to
// settle the first. Wasting is recoverable, reuse is not.
const std::string address = pool_[cursor_];
if (!WriteCursor(cursor_ + 1)) {
std::println(std::cerr,
"eurc: could not persist the address cursor — refusing "
"checkout rather than risk reusing {}", address);
return std::nullopt;
}
++cursor_;
const std::int64_t deadline =
std::chrono::duration_cast<std::chrono::seconds>(
std::chrono::system_clock::now().time_since_epoch()).count()
+ static_cast<std::int64_t>(WindowSeconds());
PaymentLink link;
// The id carries the deadline because CheckPaid is given nothing but the
// id and the amount, and this rail — unlike a processor's — has to know
// on its own when a window closed. Both halves are worth keeping in the
// ledger anyway: the address is the audit trail, the deadline explains
// why an order lapsed when it did.
link.payId = address + "@" + std::to_string(deadline);
// There is no hosted checkout to send the buyer to. The order page is
// the payment page: it already knows the order, and the address is in
// the ledger next to it.
link.payUrl = redirectUrl;
if (pool_.size() - cursor_ < kLowWaterMark) {
std::println(std::cerr, "eurc: WARNING {} addresses left after issuing {}",
pool_.size() - cursor_, address);
}
return link;
}
std::optional<PaidStatus> CheckPaid(const std::string& payId,
std::int64_t expectedMinor) override {
std::lock_guard lock(mutex_);
const std::optional<PayIdParts> parts = SplitPayId(payId);
if (!parts) return PaidStatus{ PayState::Dead, {} };
const std::string& address = parts->address;
const std::int64_t deadline = parts->deadline;
// Ask every chain before judging. A transport failure on one chain is
// NOT evidence of non-payment, so an unreachable chain poisons the whole
// answer to nullopt ("unknown, retry") rather than letting the reachable
// chains lapse an order that may well be paid on the silent one.
bool anyUnreachable = false;
for (const EurcChain& chain : chains_) {
const std::optional<std::int64_t> required = RequiredUnits(chain, expectedMinor);
if (!required) {
std::println(std::cerr, "eurc: chain '{}' has an unusable scale", chain.name);
anyUnreachable = true;
continue;
}
const std::optional<std::int64_t> balance = BalanceOf(chain, address);
if (!balance) {
anyUnreachable = true;
continue;
}
// Full cover on ONE chain. Deliberately not a sum across chains: a
// total assembled from partial transfers on several networks is not
// a payment this shop wants to accept automatically, and reading it
// as one would let two unrelated dust sends settle an invoice.
if (*balance >= *required) {
PaidStatus out;
out.state = PayState::Paid;
out.method = "eurc-" + chain.name;
return out;
}
}
if (anyUnreachable) return std::nullopt;
const std::int64_t now =
std::chrono::duration_cast<std::chrono::seconds>(
std::chrono::system_clock::now().time_since_epoch()).count();
if (now >= deadline) {
// See the header: this is not "the money bounced". The address stays
// ours, so a late payment still lands — which is why the address is
// shouted here rather than quietly dropped.
std::println(std::cerr,
"eurc: order at {} lapsed unpaid after its window — the "
"address remains ours, so a late payment will still "
"arrive there and needs settling by hand", address);
return PaidStatus{ PayState::Dead, {} };
}
return PaidStatus{ PayState::Pending, {} };
}
// What the order page renders in place of a hosted-checkout button. Reads
// only chains_ and the payId, both fixed after load — no lock, per the
// interface contract, so a slow RPC poll can never stall page rendering.
std::optional<PayInstructions> Instructions(const std::string& payId,
std::int64_t totalMinor) const override {
const std::optional<PayIdParts> parts = SplitPayId(payId);
if (!parts || totalMinor <= 0) return std::nullopt;
PayInstructions out;
out.address = parts->address;
out.deadlineUnix = parts->deadline;
// EURC is euro-denominated at par, so the token amount IS the euro
// total — same digits, different unit label. The one place that fact
// is relied on for display, and the reason there is no rate line.
out.amount = Money::FormatMinor(totalMinor);
for (const EurcChain& chain : chains_) {
PayChainOption opt;
opt.name = chain.name;
opt.contract = chain.contract;
opt.note = chain.note;
// EIP-681: a URI wallets open with token, network, recipient and
// amount pre-filled — the buyer cannot mistype what they never
// type. Base units, so the same scaling as the covering check;
// skipped when it cannot be represented, never approximated.
if (chain.chainId > 0) {
if (const auto units = RequiredUnits(chain, totalMinor)) {
opt.link = std::format("ethereum:{}@{}/transfer?address={}&uint256={}",
chain.contract, chain.chainId,
parts->address, *units);
}
}
out.chains.push_back(std::move(opt));
}
if (out.chains.empty()) return std::nullopt;
return out;
}
std::string_view Name() const override { return "eurc"; }
// Finality is minutes on every chain here, so a faster sweep would only
// spend somebody's RPC quota learning nothing. The buyer's own arrival at
// the order page still triggers one immediate poll.
std::chrono::seconds PollInterval() const override { return std::chrono::seconds(30); }
private:
static constexpr std::size_t kLowWaterMark = 25;
std::size_t WindowSeconds() const {
return cfg_.eurcWindowHours > 0
? static_cast<std::size_t>(cfg_.eurcWindowHours) * 3600u
: 24u * 3600u;
}
// cents -> token base units, saturating. Both halves are bounded by config
// and by the catalogue, but this is the number an order is judged against.
std::optional<std::int64_t> RequiredUnits(const EurcChain& chain,
std::int64_t expectedMinor) const {
if (expectedMinor <= 0) return std::nullopt;
const std::optional<std::int64_t> scale = Pow10(chain.decimals - 2);
if (!scale) return std::nullopt;
if (expectedMinor > std::numeric_limits<std::int64_t>::max() / *scale) {
return std::nullopt;
}
return expectedMinor * *scale;
}
std::optional<std::int64_t> BalanceOf(const EurcChain& chain,
const std::string& address) {
// eth_call to the token contract. The address is left-padded into a
// 32-byte ABI word: 24 zero bytes, then the 20 address bytes.
std::string data;
data.reserve(2 + 8 + 64);
data += kBalanceOfSelector;
data.append(24 * 2, '0');
data += address.substr(2);
const std::string body =
std::string(R"({"jsonrpc":"2.0","id":1,"method":"eth_call","params":[{"to":")")
+ chain.contract + R"(","data":")" + data + R"("},")" + chain.blockTag + R"("]})";
const std::optional<std::string> res = Call(chain, body);
if (!res) return std::nullopt;
const std::optional<std::int64_t> units = ParseEthCallUint(*res);
if (!units) {
std::println(std::cerr, "eurc: chain '{}' returned an undecodable balance: {}",
chain.name, res->substr(0, 200));
return std::nullopt;
}
return units;
}
// One JSON-RPC POST; nullopt on transport failure or a non-2xx answer. The
// reconciler treats nullopt as "unknown, retry" — never as unpaid or dead.
std::optional<std::string> Call(const EurcChain& chain, const std::string& body) {
const std::optional<Endpoint> ep = ParseEndpoint(chain.rpcUrl);
if (!ep) return std::nullopt;
try {
std::unique_ptr<Crafter::ClientHTTP1>& client = clients_[chain.name];
if (!client) {
client = ep->tls
? std::make_unique<Crafter::ClientHTTP1>(
ep->host, ep->port, Crafter::TLSClientCredentials{})
: std::make_unique<Crafter::ClientHTTP1>(ep->host, ep->port);
}
Crafter::HTTPRequest req;
req.method = "POST";
req.path = ep->path;
req.authority = ep->host;
req.body = body;
req.headers["content-type"] = "application/json";
req.headers["accept"] = "application/json";
req.headers["user-agent"] = "catcrafts.net-server/1.0 (+https://catcrafts.net)";
const Crafter::HTTPResponse res = client->Send(req);
if (res.status.size() != 3 || res.status[0] != '2') {
// The RPC URL can carry a key in its path; log the chain, never
// the endpoint.
std::println(std::cerr, "eurc: chain '{}' -> {} {}", chain.name,
res.status, res.body.substr(0, 200));
return std::nullopt;
}
return res.body;
} catch (const std::exception& e) {
std::println(std::cerr, "eurc: chain '{}' call failed: {}", chain.name, e.what());
clients_[chain.name].reset(); // dial fresh next time
return std::nullopt;
}
}
bool LoadChains() {
std::ifstream in(cfg_.eurcChainsPath, std::ios::binary);
if (!in) {
std::println(std::cerr, "eurc: cannot read chains file '{}'",
cfg_.eurcChainsPath.string());
return false;
}
const std::string text((std::istreambuf_iterator<char>(in)),
std::istreambuf_iterator<char>());
std::optional<std::vector<EurcChain>> parsed = ParseEurcChains(text);
if (!parsed) {
std::println(std::cerr,
"eurc: chains file '{}' is malformed — every entry needs a "
"name, an http(s) rpc, and a 20-byte contract address",
cfg_.eurcChainsPath.string());
return false;
}
chains_ = std::move(*parsed);
return true;
}
// One address per line; '#' comments and blank lines ignored. A malformed
// line is fatal rather than skipped: the pool is the list of places this
// shop will tell strangers to send money, and a line that does not parse is
// as likely to be a mangled good address as a stray note.
//
// Addresses must be COPIED from the wallet that generated them. The checksum
// case cannot be verified here (see IsAddress), so a hand-retyped address
// that stays hex will be accepted, published to a buyer, and paid to a place
// nobody holds a key for.
bool LoadPool() {
std::ifstream in(cfg_.eurcPoolPath, std::ios::binary);
if (!in) {
std::println(std::cerr, "eurc: cannot read address pool '{}'",
cfg_.eurcPoolPath.string());
return false;
}
std::set<std::string> seen;
std::string line;
std::size_t lineNo = 0;
while (std::getline(in, line)) {
++lineNo;
if (const std::size_t hash = line.find('#'); hash != std::string::npos) {
line.erase(hash);
}
while (!line.empty() && (line.back() == ' ' || line.back() == '\t'
|| line.back() == '\r')) {
line.pop_back();
}
std::size_t start = 0;
while (start < line.size() && (line[start] == ' ' || line[start] == '\t')) {
++start;
}
const std::string entry = LowerAscii(std::string_view(line).substr(start));
if (entry.empty()) continue;
if (!IsAddress(entry)) {
std::println(std::cerr, "eurc: address pool line {} is not an address",
lineNo);
return false;
}
// A duplicate in the pool is the reuse bug wearing a different hat.
if (!seen.insert(entry).second) {
std::println(std::cerr,
"eurc: address pool line {} repeats an earlier address",
lineNo);
return false;
}
pool_.push_back(entry);
}
if (pool_.empty()) {
std::println(std::cerr, "eurc: address pool '{}' is empty",
cfg_.eurcPoolPath.string());
return false;
}
return true;
}
// The cursor is the high-water mark of addresses ever issued. Missing reads
// as zero (a fresh pool); anything unparseable is fatal at load rather than
// silently rewinding to the start of a pool whose head is already published.
std::size_t ReadCursor() const {
std::ifstream in(CursorPath(), std::ios::binary);
if (!in) return 0;
std::size_t value = 0;
if (!(in >> value)) {
std::println(std::cerr, "eurc: cursor file '{}' is unreadable — treating "
"the pool as exhausted rather than reissuing",
CursorPath().string());
return std::numeric_limits<std::size_t>::max();
}
return value;
}
bool WriteCursor(std::size_t value) const {
// Write-then-rename so a crash mid-write cannot leave a truncated
// cursor that reads as a smaller number than the addresses already out.
std::filesystem::path tmp = CursorPath();
tmp += ".tmp";
{
std::ofstream out(tmp, std::ios::binary | std::ios::trunc);
if (!out) return false;
out << value << '\n';
out.flush();
if (!out) return false;
}
std::error_code ec;
std::filesystem::rename(tmp, CursorPath(), ec);
return !ec;
}
std::filesystem::path CursorPath() const {
std::filesystem::path p = cfg_.eurcPoolPath;
p += ".cursor";
return p;
}
RailConfig cfg_;
std::vector<EurcChain> chains_;
std::vector<std::string> pool_;
std::size_t cursor_ = 0;
std::mutex mutex_;
std::map<std::string, std::unique_ptr<Crafter::ClientHTTP1>> clients_;
};
} // namespace
std::unique_ptr<PaymentRail> MakeEurcRail(const RailConfig& config) {
auto rail = std::make_unique<EurcRail>(config);
if (!rail->Load()) return nullptr;
return rail;
}
} // namespace Catcrafts::Server

View file

@ -304,6 +304,29 @@ HTTPResponse RenderPage(std::string_view target) {
view.colorLabel = order->color;
}
// Self-hosted rails have no provider page to resume at — the order
// page IS the payment page, so ask the rail what to render. Hosted
// rails return nullopt and keep their button. Only while awaiting: a
// paid page repeating "send money here" would read as a second ask.
if (order->status == "awaiting_payment") {
if (const PaymentRail* rail = gRails.For(order->payChoice)) {
if (auto instr = rail->Instructions(order->payId, order->totalMinor)) {
OrderCryptoPay pay;
pay.address = instr->address;
pay.amount = instr->amount;
const std::int64_t now =
std::chrono::duration_cast<std::chrono::seconds>(
std::chrono::system_clock::now().time_since_epoch()).count();
pay.minutesLeft = (instr->deadlineUnix - now) / 60;
for (auto& c : instr->chains) {
pay.chains.push_back({ std::move(c.name), std::move(c.contract),
std::move(c.link), std::move(c.note) });
}
view.cryptoPay = std::move(pay);
}
}
}
// The indicative national-currency line: ECB reference rates baked in
// at build time, converted to whole units, labelled with the rate
// date. Purely informative — the euro amount is the charge.
@ -893,7 +916,7 @@ namespace {
// configured rail's cadence, because that rail's orders deserve it, so
// "poll on every pass" would silently poll the slower provider at the faster
// one's rate — with two rails that is no longer a rounding error but double
// the request volume CoinGate was promised. `first` drives the age backoff,
// the request volume the slower one was promised. `first` drives the age backoff,
// `last` enforces the interval; keeping them apart also retires the modulo
// pacing that used to approximate this with one.
void ReconcilerLoop(const std::stop_token& stop) {

View file

@ -269,12 +269,9 @@ private:
} // namespace
// The roster has one home, here; the CoinGate constructor is declared by its
// own unit. An unrecognised mode is "off" rather than an error, and the caller
// (main) is what refuses to start on a mode it did not expect — a rail that
// silently half-exists would be worse than either.
std::unique_ptr<PaymentRail> MakeCoingateRail(const RailConfig& config);
// The roster has one home, here. An unrecognised mode is "off" rather than an
// error, and the caller (main) is what refuses to start on a mode it did not
// expect — a rail that silently half-exists would be worse than either.
std::unique_ptr<PaymentRail> MakeRail(const RailConfig& config) {
// The fake rail keeps the slot's own name so the ledger, the startup line
// and the logs still say which half of the checkout ran in a test.
@ -284,8 +281,12 @@ std::unique_ptr<PaymentRail> MakeRail(const RailConfig& config) {
if (config.mode == "fake-crypto") {
return std::make_unique<FakeRail>(config.statePath, "fake-crypto");
}
if (config.mode == "mollie") return std::make_unique<MollieRail>(config);
if (config.mode == "coingate") return MakeCoingateRail(config);
if (config.mode == "mollie") return std::make_unique<MollieRail>(config);
// "eurc" is the one mode that can fail to construct for a reason other than
// a typo: its chains file or address pool may not load. It returns nullptr
// there, which main reports as an unknown rail — see the note in main about
// why that message names the files.
if (config.mode == "eurc") return MakeEurcRail(config);
return nullptr; // "off"
}

View file

@ -25,8 +25,8 @@ No permission is granted to copy, modify, distribute, or create derivative works
// at the last known prices, and a hand-placed cache file is how dev and e2e
// get a table with no account at all.
//
// Like the CoinGate rail, this code has not run against the real API — no
// credentials existed at build time. ParseSendcloudMethods is exercised by the
// This code has not run against the real API — no credentials existed at
// build time. ParseSendcloudMethods is exercised by the
// self-test against a canned response; the fetch around it is thin. The one
// thing to verify against a live payload is the weight fields: this reads
// `min_weight`/`max_weight` as kilogram strings, which is what the v2 docs

File diff suppressed because it is too large Load diff

View file

@ -69,9 +69,10 @@ export namespace Catcrafts::Server {
// rail issued the link, and so which one
// may confirm it. Always set: checkout
// normalises before writing.
std::string payUrl; // the provider's hosted checkout link
std::string payUrl; // the provider's hosted checkout link;
// for the EURC rail, the order page itself
std::string payId; // provider payment id ("tr_…" at Mollie,
// a decimal order id at CoinGate)
// "<address>@<deadline>" at the EURC rail)
std::string paidVia; // method that settled it ("ideal", "bitcoin")
std::string paidAt; // ISO 8601 of the FIRST paid event; empty =
// never paid. A later cancel (a refund)
@ -239,7 +240,7 @@ export namespace Catcrafts::Server {
// The registered business identity — on every invoice and at the foot of
// every order email. One definition, like the rest of the compiled-in
// authored content; the selftest pins the values.
// authored content; the ShouldBuildInvoices test pins the values.
inline constexpr std::string_view kSellerName = "Catcrafts";
inline constexpr std::string_view kSellerStreet = "Chico Mendesring 256";
inline constexpr std::string_view kSellerCity = "3315NN Dordrecht";
@ -326,16 +327,35 @@ export namespace Catcrafts::Server {
};
// What a poll learned about one payment. Pending and Dead are different
// answers on purpose: both providers EXPIRE unpaid orders — Mollie after
// its own window, CoinGate after two hours (twenty minutes once a coin is
// picked) — and an order whose payment can never arrive should lapse
// rather than sit "awaiting" forever.
// answers on purpose: an unpaid order does not stay payable forever —
// Mollie expires its payments after its own window, and the EURC rail
// closes its own (24 hours by default) — and an order whose payment can
// never arrive should lapse rather than sit "awaiting" forever.
enum class PayState { Pending, Paid, Dead };
struct PaidStatus {
PayState state = PayState::Pending;
std::string method; // "ideal" | "creditcard" | "bitcoin" | …
};
// Self-hosted payment instructions for the order page. A hosted rail sends
// the buyer to the provider's checkout and returns nullopt here; a
// self-hosted rail has no such page, so the order page must itself say
// where the money goes. One chain option per network the rail watches, in
// the rail's configured order — the file order IS the display order, which
// is how "the cheap chain first" stays configuration.
struct PayChainOption {
std::string name; // "base" — also the ledger via suffix
std::string contract; // token contract, for the buyer to verify
std::string link; // EIP-681 URI a wallet can open; may be empty
std::string note; // optional display hint ("lowest fees")
};
struct PayInstructions {
std::string address; // where the money goes
std::string amount; // decimal token amount ("570.43")
std::int64_t deadlineUnix = 0;
std::vector<PayChainOption> chains;
};
class PaymentRail {
public:
virtual ~PaymentRail() = default;
@ -349,6 +369,15 @@ export namespace Catcrafts::Server {
// expired/canceled/failed kills an order.
virtual std::optional<PaidStatus> CheckPaid(const std::string& payId,
std::int64_t expectedMinor) = 0;
// What the order page should tell the buyer to do, for rails without a
// hosted checkout. Default nullopt: rails with a provider page keep
// sending the buyer there. Const and lock-free by contract — it reads
// only configuration fixed at load.
virtual std::optional<PayInstructions> Instructions(const std::string& payId,
std::int64_t totalMinor) const {
(void)payId; (void)totalMinor;
return std::nullopt;
}
virtual std::string_view Name() const = 0;
// How often the reconciler sweeps. The fake rail returns something
// tiny so tests are fast; the real providers get a respectful cadence.
@ -356,11 +385,19 @@ export namespace Catcrafts::Server {
};
struct RailConfig {
std::string mode; // "off" | "fake" | "mollie" | "coingate"
std::string apiKey; // mollie: live_… or test_…; coingate: its token
bool sandbox = false; // coingate: api-sandbox.coingate.com
std::string mode; // "off" | "fake" | "mollie" | "eurc"
std::string apiKey; // mollie: live_… or test_…
std::filesystem::path statePath; // fake: the paid marker
std::string redirectBase = "https://catcrafts.net";
// eurc: the self-hosted rail holds no credential at all — what it needs
// instead is a list of chains to watch and a list of addresses it is
// allowed to hand out. Both are files rather than environment values
// because both are lists, and the pool in particular is edited by a
// human topping it up from the wallet.
std::filesystem::path eurcChainsPath;
std::filesystem::path eurcPoolPath;
int eurcWindowHours = 24; // 0 or less means the 24h default
};
// nullptr for mode "off" — that slot then offers no payment choice.
@ -372,7 +409,7 @@ export namespace Catcrafts::Server {
// advertise a way to pay the server would then refuse.
struct PaymentRails {
std::unique_ptr<PaymentRail> bank; // Mollie: iDEAL, cards, transfer
std::unique_ptr<PaymentRail> crypto; // CoinGate: on-chain and Lightning
std::unique_ptr<PaymentRail> crypto; // EURC: self-hosted, on-chain
bool Any() const { return bank != nullptr || crypto != nullptr; }
// The rail that owns a stored order, by its recorded choice. Total on
@ -399,28 +436,47 @@ export namespace Catcrafts::Server {
};
std::optional<MolliePayment> ParseMolliePayment(std::string_view json);
// Parsed essentials of a CoinGate /api/v2/orders object. Same shape and
// same reason as MolliePayment: the parser is the part worth testing.
//
// `id` is a JSON NUMBER on the wire ("id":538) rather than a string, so it
// is rendered to decimal here and travels through the ledger as text like
// every other payment id.
struct CoingateOrder {
std::string id;
std::string status; // new|pending|confirming|paid|invalid|
// expired|canceled|refunded|partially_refunded
std::string payCurrency; // the coin the shopper picked; empty until then
std::string payUrl; // the hosted invoice, present while payable
std::int64_t priceMinor = 0; // price_amount, and only when EUR
};
std::optional<CoingateOrder> ParseCoingateOrder(std::string_view json);
// Exact decimal-string-to-minor-units parser for the amounts both provider
// APIs quote as strings ("614.00" -> 61400). Rejects anything that is not
// Exact decimal-string-to-minor-units parser for the amounts Mollie's API
// quotes as strings ("614.00" -> 61400). Rejects anything that is not
// a plain non-negative decimal with at most two fraction digits — no
// floats touch money on the way in either. Exported for the self-test.
std::optional<std::int64_t> ParseAmountToMinor(std::string_view s);
// One chain the EURC rail watches. Every field is configuration because
// every field is a fact about the world rather than about this shop:
// Circle deploys to a new chain, an RPC endpoint moves, a token is
// redeployed. See Catcrafts.Server-Eurc.cpp for why one address covers all
// of them at once.
struct EurcChain {
std::string name; // ledger via suffix: "base" -> "eurc-base"
std::string rpcUrl;
std::string contract; // the EURC token contract on this chain
std::string blockTag = "finalized";
int decimals = 6;
// For the order page. chainId names the network in the EIP-681 wallet
// link (1 = Ethereum, 8453 = Base); 0 omits the link rather than
// guessing. note is a short display hint ("lowest fees") — copy is
// configuration here because fee facts change without a deploy.
std::int64_t chainId = 0;
std::string note;
};
// nullopt for a malformed file — partial success is refused, because a
// chain that silently dropped out of the list is a chain whose payments
// stop being noticed while the shop still advertises it.
std::optional<std::vector<EurcChain>> ParseEurcChains(std::string_view json);
// A uint256 hex word as eth_call returns it, reduced to an int64 and
// saturating rather than wrapping. nullopt covers a JSON-RPC error object
// too: "the node refused" must never read as "the balance is zero".
// Exported for the self-test — the decoding is where a mistake costs money,
// the HTTP around it is thin.
std::optional<std::int64_t> ParseEthCallUint(std::string_view json);
// nullptr when the chains file or the address pool will not load. The rail
// holds no key and no credential; it can only ever hand out an address it
// was given.
std::unique_ptr<PaymentRail> MakeEurcRail(const RailConfig& config);
// ── shipping rates ────────────────────────────────────────────────
//
// Live per-country, per-weight-bracket rates from Sendcloud's

View file

@ -228,7 +228,7 @@ export const std::vector<LegalPage>& LegalPages() {
{
.slug = "privacy",
.title = "Privacy",
.updated = "2026-08-08",
.updated = "2026-08-14",
.lede = "What this site collects, why, and how to get rid of it. Written to describe what the code actually does. If you find a discrepancy, the code is the bug and a report is very welcome.",
.sections = {
{ "Who is responsible",
@ -238,7 +238,7 @@ export const std::vector<LegalPage>& LegalPages() {
{ "Orders",
{
"Placing an order stores what fulfilling it requires: your email address, the recipient name and shipping address, the country, and the order itself (product, amounts, timestamps, payment reference and status). Nothing else is asked for and nothing else is kept. The legal basis is the contract: this data is what shipping you a phone and issuing an invoice consist of.",
"Payment happens on the payment provider's own pages, never here. Choosing bank or card sends you to Mollie, a Dutch licensed payment institution; choosing cryptocurrency sends you to CoinGate, a Lithuanian payment institution licensed under the EU crypto-asset regulation. Either way Catcrafts never sees card numbers, bank credentials or wallet keys, and learns only which order was paid, for how much, and by which method. What the provider you choose processes about you is between you and them under their own privacy policy.",
"Bank and card payment happens on Mollie's own pages, never here — Mollie is a Dutch licensed payment institution, and Catcrafts never sees card numbers or bank credentials, only which order was paid, for how much, and by which method. What Mollie processes about you is between you and them under their own privacy policy. Cryptocurrency payment involves no provider at all: the order page shows a receiving address that belongs to Catcrafts, and paying it is a transaction on a public blockchain. Catcrafts never sees your wallet keys, and learns nothing beyond what the chain itself records — but note that a blockchain is public and permanent by nature, so the paying address, amount and time of your transaction are visible to anyone; only the order record described above connects them to you, and that record stays with Catcrafts.",
"The order status page lives at an unguessable link. Anyone holding the link can read that order's status and totals, so treat it like a receipt on your desk and don't post it anywhere public.",
} },
{ "How long it is kept",
@ -250,7 +250,7 @@ export const std::vector<LegalPage>& LegalPages() {
{ "What this site does not do",
{
"No analytics in your browser. No cookies, none at all, which is why there is no cookie banner. No third-party scripts, no fonts loaded from anyone else's server, no embedded video, no social buttons, no advertising, no profiling, no automated decision-making.",
"Everything the browser loads comes from catcrafts.net. Following a link out (to a fediverse thread, to Forgejo, to the Mollie or CoinGate payment page) puts you on that site under its terms, and Catcrafts has no visibility into what happens there.",
"Everything the browser loads comes from catcrafts.net. Following a link out (to a fediverse thread, to Forgejo, to the Mollie payment page) puts you on that site under its terms, and Catcrafts has no visibility into what happens there.",
} },
{ "Server logs",
{
@ -291,15 +291,15 @@ export const std::vector<LegalPage>& LegalPages() {
{
.slug = "terms",
.title = "Terms",
.updated = "2026-08-13",
.updated = "2026-08-14",
.lede = "The terms for buying from this shop. Written to be read: short sections, no boilerplate imported from anywhere, and every claim checkable against what the site actually does.",
.sections = {
{ "Ordering and payment",
{
"Submitting the order form creates an order and a payment link with the provider you picked. The order is an offer to buy; the contract forms when the payment arrives. Until then nothing is owed: an unpaid order simply lapses and can be ignored. A crypto invoice lapses quickly — within hours, and within minutes once a coin is selected — so an unfinished one usually means ordering again rather than waiting.",
"Submitting the order form creates an order — with a Mollie payment link for bank and card, or with a receiving address of its own for cryptocurrency. The order is an offer to buy; the contract forms when the payment arrives. Until then nothing is owed: an unpaid order simply lapses and can be ignored. A cryptocurrency order's address stays reserved for about a day; after that the order lapses like any other unpaid one.",
"Prices are in euros, and euros are what is charged; any amount shown in another currency is indicative only, converted at the ECB reference rate of the date shown. Inside the EU the shown price includes 21% Dutch VAT. Outside the EU the sale is a zero-rated export at the derived ex-VAT price, and the price then excludes import duty, import VAT, tariffs and any carrier handling or brokerage fee. Those charges arise on arrival in your country, are levied by the carrier or your customs authority, and are solely a matter between you and them: Catcrafts does not collect them, cannot bindingly estimate them, is not a party to their assessment, and refusal to pay them does not undo the sale. Your bank or card sets the actual euro conversion rate for whatever you pay with.",
"Payment is handled by a licensed provider, never on this site: bank and card payments by Mollie, a Dutch payment institution, and cryptocurrency by CoinGate, a Lithuanian payment institution authorised under the EU crypto-asset regulation. Catcrafts never sees your card number, bank credentials or wallet keys.",
"Paying in cryptocurrency changes how the money moves, not what is owed or what you are owed. The price is the euro amount; the provider fixes the exchange rate when you open the invoice, and that rate is what settles it. Every refund under the sections below is likewise calculated and owed in euros — if the coin has moved against you in the meantime, the euro figure is still the euro figure. A crypto payment can only be refunded to a wallet address you give at the time, since there is nothing to send it back to otherwise.",
"Bank and card payments are handled by Mollie, a Dutch licensed payment institution, on its own pages — Catcrafts never sees your card number or bank credentials. Cryptocurrency payments involve no provider: they are made in EURC, a euro-denominated stablecoin, directly to a wallet address held by Catcrafts and shown on the order page. Catcrafts never sees your wallet keys.",
"Paying in cryptocurrency changes how the money moves, not what is owed or what you are owed. Payment is accepted in EURC only, which is denominated in euros: the amount to send is exactly the euro price, with no exchange rate involved, and it must arrive in full on one of the networks the order page lists — a payment split across several networks cannot be accepted automatically. Every refund under the sections below is likewise owed in euros and is paid in EURC, to a wallet address you give at the time, since there is nothing to send it back to otherwise.",
"For support related to orders please contact orders@catcrafts.net"
} },
{ "Fulfilment",

View file

@ -201,9 +201,9 @@ export struct Checkout {
};
// How the buyer's money moves. Two KINDS of money movement, not two brand
// names: which provider serves each is the server's configuration, and writing
// the kind (rather than "mollie"/"coingate") into the form and the ledger means
// swapping a provider cannot retroactively rewrite what a buyer picked.
// names: which rail serves each is the server's configuration, and writing
// the kind (rather than "mollie"/"eurc") into the form and the ledger means
// swapping a rail cannot retroactively rewrite what a buyer picked.
//
// These strings are the wire format — they travel in the form post and land
// verbatim in the order log — so they are defined once, here, where both the

View file

@ -158,6 +158,10 @@ export SafeHtml Url(std::string_view attrName, std::string_view href) {
StartsWithNoCase(cleaned, "https://")
|| StartsWithNoCase(cleaned, "http://")
|| StartsWithNoCase(cleaned, "mailto:")
// EIP-681 payment links on the order page. Like mailto:, a pure
// protocol-handler scheme: the browser hands it to a wallet or does
// nothing. No script context is reachable through it.
|| StartsWithNoCase(cleaned, "ethereum:")
// Site-relative, but NOT protocol-relative ("//evil.example" would
// leave the origin while looking like a path).
|| (cleaned.size() >= 1 && cleaned[0] == '/'

View file

@ -344,6 +344,24 @@ export Financials LoadFinancials(std::string_view json) {
// Everything the order status page needs to render — a projection of the
// server's order record, not the record itself. The renderer stays a pure
// function in Shared; the server owns storage and fills this in.
// Self-hosted crypto payment instructions, rendered on the order page when the
// rail has no hosted checkout to send the buyer to. Everything is pre-formatted
// text: the renderer should not know what a chain id or a token decimal is, any
// more than it knows where ECB rates come from.
export struct OrderCryptoPay {
std::string address; // where the money goes, "0x…"
std::string amount; // decimal EURC amount ("570.43") — equals the
// euro total; EURC is euro-denominated at par
std::int64_t minutesLeft = 0; // until the window closes; <= 0 = closed
struct Chain {
std::string name; // "base"
std::string contract; // token contract, shown so the buyer can verify
std::string link; // EIP-681 wallet URI; empty = no link rendered
std::string note; // short hint ("lowest fees"), may be empty
};
std::vector<Chain> chains; // in display order — first is the recommended one
};
export struct OrderView {
std::string token; // the capability that IS the URL — never logged
std::string reference; // short human code, quoted in the bank transfer
@ -363,6 +381,9 @@ export struct OrderView {
std::int64_t shippingMinor = 0;
std::int64_t totalMinor = 0;
bool vatIncluded = false;
// Present only for an awaiting order on a rail without a hosted checkout;
// the page then renders instructions instead of a "resume payment" button.
std::optional<OrderCryptoPay> cryptoPay;
};
// An entry on the demos page.

View file

@ -224,9 +224,9 @@ export Totals ComputeTotals(std::int64_t unitGrossMinor, std::int64_t quantity,
// ── indicative currency display ───────────────────────────────────────
//
// Orders are charged in euros, always — both rails collect EUR (CoinGate
// converts the coin at a locked rate and settles euro) and the invoice is
// EUR. But a Canadian reading "€614" has to do mental arithmetic to know what
// Orders are charged in euros, always — both rails collect EUR (crypto is
// accepted in EURC, a euro stablecoin, so the token amount IS the euro
// amount) and the invoice is EUR. But a Canadian reading "€614" has to do mental arithmetic to know what
// their card will actually take, so the order page also shows an INDICATIVE
// conversion in the buyer's national currency, from ECB reference rates baked
// in at build time. Indicative is the whole contract: the buyer's bank sets

View file

@ -804,11 +804,11 @@ SafeHtml RenderCheckoutForm(const Product& product,
R"(bank transfer. Handled by Mollie.</span></label>)"
R"(<label class="pay-option">)"
R"(<input type="radio" name="pay"{}{}>)"
R"(<span><strong>Cryptocurrency</strong> &mdash; Bitcoin and Lightning, )"
R"(stablecoins and the other coins CoinGate lists. You pay the euro )"
R"(total at the exchange rate CoinGate locks when you open the )"
R"(invoice; crypto invoices expire quickly, so pay soon after )"
R"(ordering or simply order again.</span></label>)"
R"(<span><strong>Cryptocurrency</strong> &mdash; EURC, a euro )"
R"(stablecoin, paid from your own wallet. The amount to send is the )"
R"(euro total exactly, no exchange rate; the receiving address and )"
R"(the networks it takes appear on the order page, and stay reserved )"
R"(for about a day.</span></label>)"
R"({})"
R"(</fieldset>)",
Attr("value", std::string(Form::kPayBank)),
@ -1242,18 +1242,86 @@ export RenderedPage RenderOrderStatus(const OrderView& o, std::string_view indic
// the payment on arrival. Reaching it means they abandoned the payment,
// so it reads as "resume", not as an alarming limbo.
SafeHtml payBlock;
if (awaiting && !o.payUrl.empty()) {
// Self-hosted crypto first: its payUrl is this very page, so the button
// branch would render a link to where the buyer already stands.
if (awaiting && o.cryptoPay) {
const OrderCryptoPay& pay = *o.cryptoPay;
// One list item per network, in the given order — the first entry is
// the recommendation, which is how "cheapest chain first" reaches the
// buyer as layout rather than as a lecture.
std::vector<SafeHtml> items;
for (const OrderCryptoPay::Chain& c : pay.chains) {
std::string label = c.name;
if (!label.empty() && label[0] >= 'a' && label[0] <= 'z') {
label[0] = static_cast<char>(label[0] - 'a' + 'A');
}
items.push_back(Format(
R"(<li><strong>{}</strong>{}{}<br>)"
R"(<small>EURC contract <code>{}</code></small></li>)",
Escape(label),
c.note.empty() ? SafeHtml{} : Format(" &mdash; {}", Escape(c.note)),
c.link.empty() ? SafeHtml{}
: Format(R"( &middot; <a{}>open in your wallet</a>)",
Url("href", c.link)),
Escape(c.contract)));
}
// The window line tells the truth for both signs of minutesLeft. A
// closed window does NOT mean sent money is gone — the address stays
// ours — so the copy says where it went instead of leaving a buyer
// staring at money that "vanished".
const SafeHtml windowLine = pay.minutesLeft > 0
? Format(
R"(<p class="order__note">This address is reserved for this order )"
R"(for about {} more {}. This page checks automatically and )"
R"(confirms once the full amount has arrived.</p>)",
Num(pay.minutesLeft >= 120 ? pay.minutesLeft / 60 : pay.minutesLeft),
pay.minutesLeft >= 120 ? Raw("hours") : Raw("minutes"))
: Raw(R"(<p class="order__note">The payment window for this order has )"
R"(closed and the order will lapse. If you already sent EURC it )"
R"(is not lost &mdash; it arrived at the address above; contact )"
R"(<a href="mailto:info@catcrafts.net">info@catcrafts.net</a> )"
R"(and it will be settled by hand.</p>)");
SafeHtml indicativeLine = indicative.empty() ? SafeHtml{} : Format(
R"(<p class="order__indicative">{}, indicative only. The charge is )"
R"(the euro amount above.</p>)",
Escape(indicative));
payBlock = Format(
R"(<section class="section">)"
R"(<h2 class="section__title">Pay with EURC</h2>)"
R"(<p>Send <strong>{} EURC</strong> to this address &mdash; one )"
R"(network, one payment:</p>)"
R"(<p><code class="order__address">{}</code></p>)"
R"(<ul class="order__chains">{}</ul>)"
R"({})"
R"(<p class="order__note">EURC is pegged to the euro, so the amount )"
R"(is exactly the euro total &mdash; no exchange rate. Send EURC only, )"
R"(and only on a network listed above. If your exchange deducts a )"
R"(withdrawal fee, send the missing rest to the same address; the )"
R"(order confirms once the full amount sits on one network. Your )"
R"(order reference is <strong>{}</strong>.</p>)"
R"({})"
R"(</section>)",
Escape(pay.amount),
Escape(pay.address),
Join(items),
indicativeLine,
Escape(o.reference),
windowLine);
} else if (awaiting && !o.payUrl.empty()) {
const bool crypto = o.payChoice == Form::kPayCrypto;
SafeHtml indicativeLine = indicative.empty() ? SafeHtml{} : Format(
R"(<p class="order__indicative">{}, indicative only. The charge is )"
R"(the euro amount above; your bank or card sets the actual conversion )"
R"(rate.</p>)",
Escape(indicative));
// What is waiting behind the button differs by rail, and so does what
// "left uncompleted" costs the buyer: a Mollie payment can be resumed
// for a good while, a crypto invoice expires in hours or minutes. A
// page that promised the crypto buyer their link would keep would be
// lying to exactly the person most likely to come back to it late.
// What is waiting behind the button differs by rail. The real crypto
// rail never reaches this branch (its instructions render above); a
// crypto choice here means a hosted stand-in — the fake rail in tests
// — so its copy stays generic rather than naming coins.
payBlock = Format(
R"(<section class="section">)"
R"(<h2 class="section__title">Complete your payment</h2>)"
@ -1265,13 +1333,10 @@ export RenderedPage RenderOrderStatus(const OrderView& o, std::string_view indic
R"(</section>)",
indicativeLine,
Url("href", o.payUrl), Escape(Money::FormatEuro(o.totalMinor)),
crypto ? Raw("The invoice takes Bitcoin, Lightning, stablecoins and the "
"other coins CoinGate lists;")
crypto ? Raw("The payment page completes your crypto payment;")
: Raw("The payment page offers iDEAL, cards and a bank transfer;"),
Escape(o.reference),
crypto ? Raw("Crypto invoices expire quickly &mdash; if this one has, "
"the order simply lapses and you can order again.")
: Raw("A payment left uncompleted simply lapses the order."));
Raw("A payment left uncompleted simply lapses the order."));
} else if (o.status == "paid") {
payBlock = Format(
R"(<section class="section"><h2 class="section__title">What happens now</h2>)"

View file

@ -737,6 +737,19 @@ treatment it replaces, which read as the wrong category for the work.
.order__vat { color: var(--text-faint); font-size: var(--step--1); margin-top: var(--s-2); }
.order__indicative { color: var(--text-muted); font-size: var(--step--1); max-width: var(--measure); }
.order__note { color: var(--text-muted); font-size: var(--step--1); max-width: var(--measure); }
/* The EURC receiving address: selectable as one run, breakable anywhere
a 42-character hex string has no natural wrap point. */
.order__address {
display: block;
padding: var(--s-1) var(--s0);
word-break: break-all;
user-select: all;
-webkit-user-select: all;
}
.order__chains { list-style: none; padding-left: 0; }
.order__chains li { margin-block-end: var(--s-1); max-width: var(--measure); }
.order__chains small { color: var(--text-faint); }
.order__chains small code { font-size: inherit; word-break: break-all; }
.order__keep {
margin-top: var(--s2);
color: var(--text-faint);

View file

@ -0,0 +1,70 @@
/*
catcrafts.net
Copyright (C) 2026 Catcrafts
The source code of this website is made available for viewing purposes only.
No permission is granted to copy, modify, distribute, or create derivative works.
*/
// The bug this suite 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.
//
// Skips (exit 77) when no wasm bundle sits under bin/ — build the web product
// first. CI re-runs exactly this suite after the wasm build for that reason.
import std;
import Catcrafts.E2eHarness;
using namespace Catcrafts::E2e;
int main(int argc, char** argv) {
TestServer srv(argv[1], 8213);
const std::string page = srv.Body("/demos/raytracer");
if (page.find("<script src=") == std::string::npos) {
std::println("no bundle under bin/, so no boot scripts were emitted — "
"build the wasm product first");
return 77;
}
Check(page.find("<base href=\"/\">") != std::string::npos,
"wasm page sets <base href=\"/\">");
// Absolute script srcs regardless of the <base>, so the tags stay correct
// even if the base is ever removed.
{
const std::regex relativeSrc(R"(<script\s[^>]*src="[^"/:])");
Check(!std::regex_search(page, relativeSrc),
"every boot script src is absolute");
}
// 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.
{
const std::regex urlAttr(R"lit((href|src|action)="([^"]*)")lit");
std::size_t relative = 0;
for (auto it = std::sregex_iterator(page.begin(), page.end(), urlAttr);
it != std::sregex_iterator(); ++it) {
const std::string url = (*it)[2].str();
const bool absolute = url.starts_with("/")
|| url.starts_with("http://")
|| url.starts_with("https://")
|| url.starts_with("mailto:");
if (!absolute) ++relative;
}
Check(relative == 0, "wasm page has no relative URL for <base> to retarget",
std::format("{} URL(s) would be retargeted by the base tag", relative));
}
return Finish();
}

View file

@ -0,0 +1,149 @@
/*
catcrafts.net
Copyright (C) 2026 Catcrafts
The source code of this website is made available for viewing purposes only.
No permission is granted to copy, modify, distribute, or create derivative works.
*/
// The invoice builder and the order confirmation email — the two documents a
// paying customer actually receives, including the VAT treatment on each and
// the header-injection guard on the address that goes into the envelope.
import std;
import Catcrafts.Shared;
import Catcrafts.Server;
using namespace Catcrafts;
namespace {
int failures = 0;
void Check(bool ok, std::string_view what, std::string_view got = {}) {
if (ok) return;
++failures;
std::println(std::cerr, "FAIL: {}{}{}", what,
got.empty() ? "" : " got: ", got);
}
} // namespace
int main() {
Server::OrderRecord o;
o.token = "0123456789abcdef0123456789abcdef";
o.reference = "CC-TEST01";
o.invoiceNumber = "f57c6512-f012-4b91-adb3-077876480178-7";
o.invoicedAt = "2026-08-05T10:00:00Z";
o.createdAt = "2026-08-05T09:55:00Z";
o.paidVia = "ideal";
o.buyer = { "b@example.org", "Ada Lovelace", "Main St 1", "1234AB",
"Delft", "NL" };
o.quantity = 2;
o.unitMinor = 56330;
o.goodsMinor = 112660;
o.shippingMinor = 863;
o.totalMinor = 113523;
o.vatIncluded = true;
const std::string eu = Server::BuildInvoiceMarkdown(o, "Fairphone 6", "Forest Green");
Check(eu.find("# Invoice f57c6512-f012-4b91-adb3-077876480178-7") != std::string::npos,
"invoice: number heading");
Check(eu.find("* Customer number: f57c6512-f012-4b91-adb3-077876480178") != std::string::npos,
"invoice: customer series shown separately");
Check(eu.find("* Invoice number: 7") != std::string::npos,
"invoice: sequence within the series");
Check(eu.find("Chico Mendesring 256") != std::string::npos, "invoice: seller address");
Check(eu.find("3315NN Dordrecht") != std::string::npos, "invoice: seller city");
Check(eu.find("KVK 78437059") != std::string::npos, "invoice: KVK");
Check(eu.find("NL003329281B38") != std::string::npos, "invoice: VAT id");
Check(eu.find("CC-TEST01") != std::string::npos, "invoice: order reference");
Check(eu.find("Ada Lovelace") != std::string::npos, "invoice: buyer name");
Check(eu.find("Fairphone 6 — Forest Green") != std::string::npos,
"invoice: item names the colour");
Check(eu.find("VAT 21% (NL)") != std::string::npos, "invoice: EU VAT line");
Check(eu.find("€1135.23") != std::string::npos, "invoice: EU total");
Check(eu.find("zero-rated") == std::string::npos, "invoice: EU is not an export");
o.vatIncluded = false;
o.buyer.country = "GB";
o.goodsMinor = 93107;
o.shippingMinor = 2395;
o.totalMinor = 95502;
const std::string ex = Server::BuildInvoiceMarkdown(o, "Fairphone 6", "Forest Green");
Check(ex.find("VAT 0%") != std::string::npos, "invoice: export VAT 0%");
Check(ex.find("art. 146") != std::string::npos, "invoice: export legal basis");
Check(ex.find("€955.02") != std::string::npos, "invoice: export total");
// ── the order confirmation email ──────────────────────────────────
// Same order, EU shape again; the attachment stands in for the
// clearsigned invoice — the builder must carry it verbatim.
o.vatIncluded = true;
o.buyer.country = "NL";
o.goodsMinor = 112660;
o.shippingMinor = 863;
o.totalMinor = 113523;
const std::string mail = Server::BuildOrderConfirmationEmail(
o, "Fairphone 6", "Forest Green", "Catcrafts <info@catcrafts.net>",
"https://catcrafts.net/order/0123456789abcdef0123456789abcdef",
"SIGNED-INVOICE-STAND-IN\n", "Fri, 08 Aug 2026 10:00:00 +0000");
Check(mail.find("From: Catcrafts <info@catcrafts.net>\n") != std::string::npos,
"email: From header");
Check(mail.find("To: b@example.org\n") != std::string::npos, "email: To header");
Check(mail.find("Subject: Catcrafts order CC-TEST01 confirmed\n") != std::string::npos,
"email: subject carries the reference");
Check(mail.find("Date: Fri, 08 Aug 2026 10:00:00 +0000\n") != std::string::npos,
"email: date header");
Check(mail.find("Message-ID: <0123456789abcdef0123456789abcdef@catcrafts.net>\n")
!= std::string::npos,
"email: message id from the token");
Check(mail.find("MIME-Version: 1.0\n") != std::string::npos, "email: mime version");
Check(mail.find("multipart/mixed") != std::string::npos, "email: multipart");
Check(mail.find("Fairphone 6 — Forest Green × 2") != std::string::npos,
"email: item names colour and quantity");
Check(mail.find("€1135.23") != std::string::npos, "email: total");
Check(mail.find("incl. 21% NL VAT") != std::string::npos, "email: EU VAT wording");
Check(mail.find("* Paid via: ideal\n") != std::string::npos, "email: payment method");
Check(mail.find("https://catcrafts.net/order/0123456789abcdef0123456789abcdef")
!= std::string::npos,
"email: order page link");
Check(mail.find("filename=\"catcrafts-invoice-"
"f57c6512-f012-4b91-adb3-077876480178-7.md\"") != std::string::npos,
"email: attachment filename is the invoice number");
Check(mail.find("SIGNED-INVOICE-STAND-IN\n") != std::string::npos,
"email: attachment body verbatim");
Check(mail.find("--=_cc_0123456789abcdef0123456789abcdef--\n") != std::string::npos,
"email: multipart closes");
Check(mail.find("KVK 78437059") != std::string::npos, "email: footer identity");
// The export wording mirrors the invoice's VAT treatment.
o.vatIncluded = false;
o.buyer.country = "GB";
o.totalMinor = 95502;
const std::string exMail = Server::BuildOrderConfirmationEmail(
o, "Fairphone 6", "Forest Green", "Catcrafts <info@catcrafts.net>",
"https://catcrafts.net/order/x", "S\n", "Fri, 08 Aug 2026 10:00:00 +0000");
Check(exMail.find("zero-rated export") != std::string::npos,
"email: export VAT wording");
Check(exMail.find("€955.02") != std::string::npos, "email: export total");
// A single unit does not advertise a quantity.
o.quantity = 1;
const std::string one = Server::BuildOrderConfirmationEmail(
o, "Fairphone 6", "Forest Green", "Catcrafts <info@catcrafts.net>",
"https://catcrafts.net/order/x", "S\n", "Fri, 08 Aug 2026 10:00:00 +0000");
Check(one.find("Forest Green ×") == std::string::npos, "email: qty 1 stays silent");
// The last line of defence: an address that could smuggle a header
// yields NO message at all, however it got into the record.
o.buyer.email = "a@b.example\nBcc: leak@evil.example";
Check(Server::BuildOrderConfirmationEmail(
o, "F", "", "x", "u", "S", "D").empty(),
"email: header-injecting address yields no message");
if (failures != 0) {
std::println(std::cerr, "{} check(s) failed", failures);
return 1;
}
return 0;
}

View file

@ -0,0 +1,137 @@
/*
catcrafts.net
Copyright (C) 2026 Catcrafts
The source code of this website is made available for viewing purposes only.
No permission is granted to copy, modify, distribute, or create derivative works.
*/
// The format ladder. One <picture>/<video> builder serves both the cards and
// the post bodies, so these assertions cover every image and video the site
// emits — and the ordering ones matter: a browser takes the FIRST source it
// understands, so a mis-ordered ladder silently serves the wrong tier to
// everyone rather than failing visibly.
import std;
import Catcrafts.Shared;
using namespace Catcrafts;
namespace {
int failures = 0;
void Check(bool ok, std::string_view what, std::string_view got = {}) {
if (ok) return;
++failures;
std::println(std::cerr, "FAIL: {}{}{}", what,
got.empty() ? "" : " got: ", got);
}
void CheckEq(const Html::SafeHtml& actual, std::string_view expected, std::string_view what) {
Check(actual.View() == expected, what, actual.View());
}
} // namespace
int main() {
auto img = [](std::string src, std::string avif, std::string png,
std::int64_t w = 0, std::int64_t h = 0) {
PostMedia m;
m.src = std::move(src);
m.kind = "image";
m.avif = std::move(avif);
m.fallback = std::move(png);
m.width = w;
m.height = h;
return m;
};
// The full ladder: AVIF, then the mirrored original, then the PNG the <img>
// itself points at. Exactly one of the three is ever fetched.
CheckEq(Media::Tag(img("/media/x.webp", "/media/x.avif", "/media/x.png", 800, 600)),
R"(<picture><source srcset="/media/x.avif" type="image/avif">)"
R"(<source srcset="/media/x.webp" type="image/webp">)"
R"(<img class="post-media__item" loading="lazy" decoding="async" alt="")"
R"( src="/media/x.png" width="800" height="600"></picture>)",
"media: image ladder is avif, original, png");
// Alt text reaches the <img>, not the <picture> — a screen reader reads the
// img, and an alt on the wrapper is invisible to it.
Check(Media::Tag(img("/media/x.webp", "/media/x.avif", "/media/x.png"), "a cat")
.View().find(R"(alt="a cat" src="/media/x.png")") != std::string_view::npos,
"media: alt lands on the img");
// Degradation, one tier at a time. Each of these is a real state: no
// encoder on the build host, a source that was already PNG, a body image
// whose download failed so there is nothing but the original URL.
CheckEq(Media::Tag(img("/media/x.webp", "", "/media/x.png")),
R"(<picture><source srcset="/media/x.webp" type="image/webp">)"
R"(<img class="post-media__item" loading="lazy" decoding="async" alt="")"
R"( src="/media/x.png"></picture>)",
"media: no avif still offers the original above the png");
CheckEq(Media::Tag(img("/media/x.webp", "/media/x.avif", "")),
R"(<picture><source srcset="/media/x.avif" type="image/avif">)"
R"(<img class="post-media__item" loading="lazy" decoding="async" alt="")"
R"( src="/media/x.webp"></picture>)",
"media: no png leaves the original as the base");
CheckEq(Media::Tag(img("/media/x.webp", "", "")),
R"(<img class="post-media__item" loading="lazy" decoding="async" alt="")"
R"( src="/media/x.webp">)",
"media: no renditions is a bare img, as before any of this existed");
// A source that is already PNG is its own fallback, and must not be
// offered twice — once as a <source> and once as the <img>.
CheckEq(Media::Tag(img("/media/x.png", "/media/x.avif", "/media/x.png")),
R"(<picture><source srcset="/media/x.avif" type="image/avif">)"
R"(<img class="post-media__item" loading="lazy" decoding="async" alt="")"
R"( src="/media/x.png"></picture>)",
"media: a png source is not also listed as a source");
// Likewise a source that is already AVIF.
Check(Media::Tag(img("/media/x.avif", "/media/x.avif", "/media/x.png"))
.View().find("image/avif\"><source") == std::string_view::npos,
"media: an avif source is not listed twice");
// A URL is still a URL: the scheme allowlist applies to srcset exactly as
// it does to src, or the ladder becomes a way around it.
Check(Media::Tag(img("/media/x.webp", "javascript:alert(1)", "/media/x.png"))
.View().find(R"(srcset="#")") != std::string_view::npos,
"media: a hostile srcset is neutralised");
// Video is unchanged by any of this and must stay so.
{
PostMedia v;
v.src = "/media/v.mp4";
v.kind = "video";
v.poster = "/media/v.webp";
v.fallback = "/media/v.h264.mp4";
const auto out = Media::Tag(v);
Check(out.View().starts_with("<video class=\"post-media__item\" controls preload=\"metadata\""),
"media: video is still a video", out.View());
Check(out.View().find("codecs=av01") != std::string_view::npos
&& out.View().find(R"(<source src="/media/v.h264.mp4" type="video/mp4">)")
!= std::string_view::npos,
"media: AV1 then H.264, in that order");
Check(out.View().find("<picture>") == std::string_view::npos,
"media: a video is not wrapped in a picture");
}
// A path with no record renders from the path alone — the mirror failed,
// and showing the picture beats dropping the paragraph's subject.
{
const std::array<PostMedia, 1> known{
img("/media/x.webp", "/media/x.avif", "/media/x.png") };
Check(Media::Find(known, "/media/x.webp") != nullptr, "media: found by src");
Check(Media::Find(known, "/media/nope.webp") == nullptr, "media: unknown src");
const PostMedia guessed = Media::Describe(known, "https://i.example/a.webp");
Check(guessed.kind == "image" && guessed.avif.empty(),
"media: an unmirrored image is described from its path");
Check(Media::Describe(known, "https://i.example/a.mp4").kind == "video",
"media: an unmirrored video is recognised as one");
}
if (failures != 0) {
std::println(std::cerr, "{} check(s) failed", failures);
return 1;
}
return 0;
}

View file

@ -0,0 +1,167 @@
/*
catcrafts.net
Copyright (C) 2026 Catcrafts
The source code of this website is made available for viewing purposes only.
No permission is granted to copy, modify, distribute, or create derivative works.
*/
// The money layer: formatting, the VAT arithmetic, zones and the sale policy,
// the carrier weight brackets, order totals, indicative currency conversion
// and the ECB rates loader. Every price the site shows or charges goes
// through these functions.
import std;
import Catcrafts.Shared;
using namespace Catcrafts;
namespace {
int failures = 0;
void Check(bool ok, std::string_view what, std::string_view got = {}) {
if (ok) return;
++failures;
std::println(std::cerr, "FAIL: {}{}{}", what,
got.empty() ? "" : " got: ", got);
}
} // namespace
int main() {
using namespace Catcrafts::Money;
// ── formatting ────────────────────────────────────────────────────
Check(FormatMinor(58000) == "580.00", "money: wire format");
Check(FormatMinor(47934) == "479.34", "money: wire format with cents");
Check(FormatMinor(5) == "0.05", "money: sub-unit");
Check(FormatMinor(0) == "0.00", "money: zero");
Check(FormatEuro(58000) == "€580", "money: whole euros displayed bare");
Check(FormatEuro(47934) == "€479.34", "money: cents displayed when present");
// ── VAT arithmetic ────────────────────────────────────────────────
// €580.00 gross at 21%: net = 58000/1.21 = 47933.88... -> 47934 half-up.
Check(NetFromGross(58000) == 47934, "vat: net from €580 gross");
// The derived pair must reconstruct plausibly: net + vat == gross.
Check(58000 - NetFromGross(58000) == 10066, "vat: vat portion exact");
Check(NetFromGross(0) == 0, "vat: zero");
Check(NetFromGross(121) == 100, "vat: €1.21 -> €1.00 exactly");
// The gross-up direction, used to charge carrier costs without eating
// the VAT slice: €7.13 cost -> €8.63 charged, and the pair round-trips.
Check(GrossFromNet(713) == 863, "vat: gross from €7.13 net");
Check(NetFromGross(GrossFromNet(713)) == 713, "vat: gross-up round-trips");
Check(GrossFromNet(100) == 121, "vat: €1.00 -> €1.21 exactly");
Check(GrossFromNet(0) == 0, "vat: gross-up zero");
// ── zones and membership ──────────────────────────────────────────
Check(IsEuCountry("NL") && IsEuCountry("DE") && IsEuCountry("FR"), "eu: members");
Check(!IsEuCountry("GB"), "eu: UK left");
Check(!IsEuCountry("CH") && !IsEuCountry("NO"), "eu: EFTA is not EU");
Check(!IsEuCountry("CA") && !IsEuCountry("US"), "eu: north america");
Check(!IsEuCountry("nl"), "eu: lowercase is not a member (normalise first)");
Check(ZoneFor("NL") == Zone::Nl, "zone: home");
Check(ZoneFor("DE") == Zone::Eu, "zone: eu");
Check(ZoneFor("GB") == Zone::World, "zone: world");
// ── destinations the shop refuses ─────────────────────────────────
// Zones still classify US and CA (the arithmetic is destination-blind, and
// keeping it that way means one policy switch, not two); the sale is what
// stops, in SellsTo.
Check(!SellsTo("US") && !SellsTo("CA"), "policy: north america refused");
Check(SellsTo("NL") && SellsTo("DE"), "policy: EU sells");
Check(SellsTo("GB") && SellsTo("CH") && SellsTo("AU"),
"policy: the rest of the world still sells");
Check(SellsTo("us"), "policy: matched on the normalised code, like membership");
Check(ZoneFor("US") == Zone::World, "zone: refused countries still classify");
// ── carrier weight brackets ───────────────────────────────────────
// The only shipping prices that exist. A ladder covering 2 kg / 10 kg /
// 20 kg, with the 20 kg band deliberately CHEAPER than the 10 kg one —
// real carrier tariffs do that, and picking the tightest band rather than
// the cheapest one that carries the parcel would overcharge for it.
{
const std::vector<ShipBracket> ladder{ { 2000, 895 }, { 10000, 1650 },
{ 20000, 1490 } };
Check(RateFor(ladder, 700) == 895, "brackets: one unit takes the 2 kg band");
Check(RateFor(ladder, 2000) == 895, "brackets: the ceiling is inclusive");
Check(RateFor(ladder, 2001) == 1490,
"brackets: cheapest band that CARRIES it, not the tightest");
Check(RateFor(ladder, 20001) == 0, "brackets: above every band is no price");
Check(RateFor({}, 700) == 0, "brackets: an uncovered country has no price");
Check(MaxUnitsFor(ladder, 700) == 28, "brackets: units that fit one parcel");
Check(MaxUnitsFor(ladder, 25000) == 0,
"brackets: a unit heavier than every band fits nothing");
Check(MaxUnitsFor(ladder, 0) == 0, "brackets: no weight, no answer");
Check(MaxUnitsFor({}, 700) == 0, "brackets: no ladder, nothing fits");
// The table-level lookups the handler and the page both go through.
const std::vector<ShipRates> table{ { "NL", ladder }, { "JP", { { 2000, 4250 } } } };
Check(RateFor(LadderFor(table, "NL"), 700) == 895, "table: NL priced");
Check(RateFor(LadderFor(table, "JP"), 2100) == 0,
"table: JP has one light band, so two units are unshippable");
Check(LadderFor(table, "BR").empty(), "table: unlisted country is empty");
}
// ── order totals ──────────────────────────────────────────────────
// NL: gross + shipping, VAT included in both.
auto nl = ComputeTotals(58000, 1, 1500, "NL");
Check(nl.goods == 58000 && nl.shipping == 1500 && nl.total == 59500,
"totals: NL");
Check(nl.vatIncluded, "totals: NL includes VAT");
Check(nl.vatCharged == 59500 - NetFromGross(59500), "totals: NL VAT covers shipping");
auto de = ComputeTotals(58000, 1, 2500, "DE");
Check(de.goods == 58000 && de.shipping == 2500 && de.total == 60500,
"totals: EU");
// Export: net goods, world shipping, no VAT.
auto gb = ComputeTotals(58000, 1, 5500, "GB");
Check(gb.goods == 47934 && gb.shipping == 5500 && gb.total == 53434,
"totals: export");
Check(!gb.vatIncluded && gb.vatCharged == 0, "totals: export carries no VAT");
// Quantity: the export net is derived from the LINE total, not per unit —
// per-unit rounding times qty would differ by a cent here, and the JS
// preview mirrors this exact formula.
auto gb2 = ComputeTotals(57500, 2, 5500, "GB");
Check(gb2.goods == NetFromGross(115000), "totals: qty nets the line, not the unit");
Check(gb2.goods == 95041, "totals: 2× green export net exact");
auto nl2 = ComputeTotals(57500, 3, 1500, "NL");
Check(nl2.goods == 172500 && nl2.total == 174000, "totals: qty multiplies gross");
// ── indicative conversion ─────────────────────────────────────────
// €580.00 at 1.0834 USD/EUR = $628.37 -> 628 whole units.
Check(ConvertIndicative(58000, 1'083'400) == 628, "fx: converts to whole units");
Check(ConvertIndicative(58000, 1'000'000) == 580, "fx: identity rate");
auto gbp = CurrencyFor("GB");
Check(gbp.has_value() && gbp->code == "GBP", "fx: GB -> GBP");
Check(!CurrencyFor("DE").has_value(), "fx: euro country has no conversion");
Check(!CurrencyFor("XX").has_value(), "fx: unknown country has no conversion");
if (gbp) {
Check(FormatIndicative(*gbp, 920) == "≈ £920", "fx: display form");
}
// A country the shop refuses gets no localised price either — the two
// tables are kept consistent on purpose, so this is a real invariant and
// not a coincidence of the current list.
for (const std::string_view cc : NoSaleCountries()) {
Check(!CurrencyFor(cc).has_value(),
"fx: refused destinations have no display currency", cc);
}
// ── rates loader ──────────────────────────────────────────────────
const Rates r = LoadRates(
R"({"date":"2026-08-04","micro_per_eur":{"USD":1083400,"CAD":1489000}})");
Check(r.date == "2026-08-04", "rates: date");
Check(r.Find("USD") == 1'083'400, "rates: lookup");
Check(r.Find("XXX") == 0, "rates: absent is zero");
Check(LoadRates("garbage").microPerEur.empty(), "rates: malformed input yields none");
if (failures != 0) {
std::println(std::cerr, "{} check(s) failed", failures);
return 1;
}
return 0;
}

View file

@ -0,0 +1,175 @@
/*
catcrafts.net
Copyright (C) 2026 Catcrafts
The source code of this website is made available for viewing purposes only.
No permission is granted to copy, modify, distribute, or create derivative works.
*/
// 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. Parsed with our own JSON reader — the same one
// that must accept them — and checked for the facts.
import std;
import Catcrafts.Shared;
import Catcrafts.E2eHarness;
using namespace Catcrafts;
using namespace Catcrafts::E2e;
namespace {
// Every <script type="application/ld+json"> block on the page, parsed. The
// block content is pure JSON, which contains no '<', so slicing to the next
// tag is exact.
std::vector<Json::Value> ExtractLd(TestServer& srv, const std::string& path) {
const std::string body = srv.Body(path);
constexpr std::string_view kOpen = "<script type=\"application/ld+json\">";
std::vector<Json::Value> out;
for (std::size_t pos = body.find(kOpen); pos != std::string::npos;
pos = body.find(kOpen, pos + kOpen.size())) {
const std::size_t start = pos + kOpen.size();
const std::size_t end = body.find('<', start);
if (end == std::string::npos) break;
if (auto v = Json::Parse(std::string_view(body).substr(start, end - start))) {
out.push_back(std::move(*v));
}
}
return out;
}
} // namespace
int main(int argc, char** argv) {
TestServer srv(argv[1], 8212);
// ── the home identity graph ───────────────────────────────────────
// The home record is an @graph (Organization + WebSite joined by @id);
// the org node inside it must carry the registered identity.
{
bool ok = false;
for (const Json::Value& doc : ExtractLd(srv, "/")) {
const Json::Value* g = doc.Find("@graph");
if (!g || !g->IsArray()) continue;
std::size_t orgs = 0;
for (const Json::Value& node : g->array) {
if (node.Str("@type") == "Organization"
&& node.Str("vatID") == "NL003329281B38") {
++orgs;
}
}
ok = ok || orgs == 1;
}
Check(ok, "home Organization schema parses and carries the VAT identity");
}
// ── the product record ────────────────────────────────────────────
// Variants are a ProductGroup: one variant Product per colour, each with
// its own single offer — not one Product with three prices.
const std::vector<Json::Value> productLd = ExtractLd(srv, "/shop/fp6-pmos");
const Json::Value* group = nullptr;
for (const Json::Value& doc : productLd) {
if (doc.Str("@type") == "ProductGroup") group = &doc;
}
{
bool variantsOk = false;
if (group) {
if (const Json::Value* v = group->Find("hasVariant"); v && v->IsArray()
&& v->array.size() == 3) {
variantsOk = true;
for (const Json::Value& node : v->array) {
const Json::Value* offers = node.Find("offers");
variantsOk = variantsOk && offers && offers->IsObject();
}
}
}
Check(variantsOk, "product schema parses with one variant (and offer) per colour");
}
{
bool listOk = false;
for (const Json::Value& doc : ExtractLd(srv, "/shop")) {
if (doc.Str("@type") == "ItemList") {
const Json::Value* items = doc.Find("itemListElement");
listOk = items && items->IsArray() && !items->array.empty();
}
}
Check(listOk, "shop index carries an ItemList of the product pages");
}
srv.BodyHas("/shop/fp6-pmos", "\"price\":\"563.30\"", "schema price is the checkout integer");
// Merchant-grade offer fields: what Merchant Center's website-crawl feed
// reads. Shipping is published from the live carrier table at one unit's
// weight — the same integers checkout charges, so the listing cannot
// quote a rate the till won't honour; returns mirror the terms page.
srv.BodyHas("/shop/fp6-pmos", "OfferShippingDetails", "offers carry shipping details");
srv.BodyHas("/shop/fp6-pmos", "MerchantReturnPolicy", "offers carry a return policy");
srv.BodyHas("/shop/fp6-pmos", "\"sku\":\"fp6-pmos-green\"", "offers carry per-variant skus");
srv.BodyHas("/shop/fp6-pmos", "\"brand\":{\"@type\":\"Brand\",\"name\":\"Fairphone\"}",
"product carries the hardware brand");
// One entry per (transit tier, price) the carrier table produces — three,
// for the fixture's NL/DE/GB. Not a fixed property of the code any more:
// it is whatever the carrier prices, which is the point.
const Json::Value* shipping = nullptr;
if (group) {
if (const Json::Value* v = group->Find("hasVariant"); v && v->IsArray()
&& !v->array.empty()) {
if (const Json::Value* offers = v->array[0].Find("offers")) {
shipping = offers->Find("shippingDetails");
}
}
}
Check(shipping && shipping->IsArray() && shipping->array.size() == 3,
"shipping details group the carrier's rates");
// The advertised rate IS the carrier's single-unit price, and a
// destination the table does not cover is never advertised.
if (shipping && shipping->IsArray()) {
std::vector<std::string> rates;
bool au = false;
for (const Json::Value& detail : shipping->array) {
if (const Json::Value* rate = detail.Find("shippingRate")) {
rates.emplace_back(rate->Str("value"));
}
if (const Json::Value* dest = detail.Find("shippingDestination")) {
if (const Json::Value* cc = dest->Find("addressCountry"); cc && cc->IsArray()) {
for (const Json::Value& c : cc->array) au = au || c.string == "AU";
}
}
}
std::ranges::sort(rates);
Check(rates == std::vector<std::string>{ "15.00", "25.00", "55.00" },
"published shipping rates come from the carrier table");
Check(!au, "an uncovered destination is not advertised");
}
if (srv.ShopOpen()) {
srv.BodyHas("/shop/fp6-pmos", "schema.org/InStock",
"open shop maps to InStock availability");
} else {
srv.BodyHas("/shop/fp6-pmos", "schema.org/PreOrder",
"coming-soon maps to PreOrder availability");
}
// og: tags are the link-preview card on Mastodon and Lemmy — where the
// traffic actually comes from.
srv.BodyHas("/", "property=\"og:title\"", "home page has og:title");
srv.BodyHas("/shop/fp6-pmos", "og:image\" content=\"https://catcrafts.net/fp6-pmos.jpg",
"product og:image is the absolute photo URL");
// ── the person-company weld ───────────────────────────────────────
// The about page: the founder must be named in the HTML, the Person
// schema must parse, and the home page byline must link it.
srv.BodyHas("/about", "Jorijn van der Graaf", "about page names the founder");
srv.BodyHas("/", "Jorijn van der Graaf", "home page carries the founder byline");
{
bool personOk = false;
for (const Json::Value& doc : ExtractLd(srv, "/about")) {
if (const Json::Value* person = doc.Find("mainEntity")) {
personOk = personOk || person->Str("name") == "Jorijn van der Graaf";
}
}
Check(personOk, "about Person schema parses and names the founder");
}
return Finish();
}

View file

@ -0,0 +1,107 @@
/*
catcrafts.net
Copyright (C) 2026 Catcrafts
The source code of this website is made available for viewing purposes only.
No permission is granted to copy, modify, distribute, or create derivative works.
*/
// The Html layer — escaping, attribute building and the URL scheme allowlist.
// Catcrafts.Shared is the security boundary for every piece of markup the
// site emits, and this suite is the direct test of that boundary.
import std;
import Catcrafts.Shared;
using namespace Catcrafts;
namespace {
int failures = 0;
void Check(bool ok, std::string_view what, std::string_view got = {}) {
if (ok) return;
++failures;
std::println(std::cerr, "FAIL: {}{}{}", what,
got.empty() ? "" : " got: ", got);
}
void CheckEq(const Html::SafeHtml& actual, std::string_view expected, std::string_view what) {
Check(actual.View() == expected, what, actual.View());
}
} // namespace
int main() {
using namespace Catcrafts::Html;
// ── Escape ────────────────────────────────────────────────────────
CheckEq(Escape("plain"), "plain", "escape: passthrough");
CheckEq(Escape("a<b"), "a&lt;b", "escape: lt");
CheckEq(Escape("a>b"), "a&gt;b", "escape: gt");
CheckEq(Escape("a&b"), "a&amp;b", "escape: amp");
CheckEq(Escape("say \"hi\""), "say &quot;hi&quot;", "escape: dquote");
CheckEq(Escape("it's"), "it&#39;s", "escape: squote");
// Ampersand must be escaped first or the other replacements get
// double-encoded; a single pass makes that ordering bug impossible.
CheckEq(Escape("&lt;"), "&amp;lt;", "escape: no double-encode");
CheckEq(Escape("<script>alert(1)</script>"),
"&lt;script&gt;alert(1)&lt;/script&gt;", "escape: script tag");
// Non-ASCII passes through untouched — the output is UTF-8, and
// entity-encoding it would just bloat the page.
CheckEq(Escape("café ✓ 日本"), "café ✓ 日本", "escape: utf-8 passthrough");
CheckEq(Escape(""), "", "escape: empty");
// ── Num ───────────────────────────────────────────────────────────
CheckEq(Num(0), "0", "num: zero");
CheckEq(Num(-42), "-42", "num: negative");
CheckEq(Num(9007199254740993LL), "9007199254740993", "num: beyond double precision");
// ── Attr ──────────────────────────────────────────────────────────
CheckEq(Attr("class", "card"), " class=\"card\"", "attr: basic");
CheckEq(Attr("data-x", "a\"b"), " data-x=\"a&quot;b\"", "attr: value escaped");
CheckEq(Attr("class", ""), "", "attr: empty value omits attribute");
// An invalid name is a programming error, not user data. Emitting
// nothing is safer than emitting mangled markup.
CheckEq(Attr("on error", "x"), "", "attr: invalid name rejected");
CheckEq(Attr("x><script", "y"), "", "attr: name cannot break out");
// ── Url ───────────────────────────────────────────────────────────
CheckEq(Url("href", "/shop/thing"), " href=\"/shop/thing\"", "url: site-relative");
CheckEq(Url("href", "https://a.example/x"), " href=\"https://a.example/x\"", "url: https");
CheckEq(Url("href", "mailto:a@b.example"), " href=\"mailto:a@b.example\"", "url: mailto");
CheckEq(Url("href", "#reviews"), " href=\"#reviews\"", "url: fragment");
// Escaping alone would NOT make these safe: they contain no character
// that needs escaping, so only a scheme allowlist stops them.
CheckEq(Url("href", "javascript:alert(1)"), " href=\"#\"", "url: javascript: neutralised");
CheckEq(Url("href", "JaVaScRiPt:alert(1)"), " href=\"#\"", "url: case-insensitive");
CheckEq(Url("href", "data:text/html,<script>"), " href=\"#\"", "url: data: neutralised");
// Browsers strip control characters before resolving the scheme, so a
// naive prefix check would pass this straight through.
CheckEq(Url("href", "java\tscript:alert(1)"), " href=\"#\"", "url: embedded tab");
CheckEq(Url("href", " javascript:alert(1)"), " href=\"#\"", "url: leading space");
CheckEq(Url("href", "//evil.example/x"), " href=\"#\"", "url: protocol-relative blocked");
CheckEq(Url("href", "vbscript:x"), " href=\"#\"", "url: vbscript neutralised");
// ── Format ────────────────────────────────────────────────────────
// The compile-time half of this guarantee (raw std::string rejected) is
// verified by the build itself — see the negative test in the notes.
CheckEq(Format("<h2>{}</h2>", Escape("a<b")), "<h2>a&lt;b</h2>", "format: escapes flow through");
CheckEq(Format("<a{}>{}</a>", Url("href", "/x"), Escape("go")),
"<a href=\"/x\">go</a>", "format: attr + text");
CheckEq(Format("{}{}", Num(1), Num(2)), "12", "format: multiple args");
CheckEq(Format("literal"), "literal", "format: no args");
CheckEq(Format("{{literal braces}}"), "{literal braces}", "format: brace escaping");
// ── Join / concat ─────────────────────────────────────────────────
const std::array<Html::SafeHtml, 3> parts{ Escape("a"), Escape("b"), Escape("c") };
CheckEq(Join(parts, Raw(", ")), "a, b, c", "join: separator");
CheckEq(Join(std::span<const Html::SafeHtml>{}), "", "join: empty");
CheckEq(Escape("a") + Escape("<"), "a&lt;", "operator+: escapes preserved");
if (failures != 0) {
std::println(std::cerr, "{} check(s) failed", failures);
return 1;
}
return 0;
}

View file

@ -0,0 +1,89 @@
/*
catcrafts.net
Copyright (C) 2026 Catcrafts
The source code of this website is made available for viewing purposes only.
No permission is granted to copy, modify, distribute, or create derivative works.
*/
// Request provenance: the forwarded-address parser the rate limiter keys on,
// and the Origin check that gates the order form.
import std;
import Catcrafts.Shared;
import Catcrafts.Server;
using namespace Catcrafts;
namespace {
int failures = 0;
void Check(bool ok, std::string_view what, std::string_view got = {}) {
if (ok) return;
++failures;
std::println(std::cerr, "FAIL: {}{}{}", what,
got.empty() ? "" : " got: ", got);
}
} // namespace
int main() {
// The rate limiter keys on this, so getting the WRONG end of the header
// is not a cosmetic bug: the leftmost entry is client-controlled, and
// trusting it would hand every attacker an endless supply of identities.
{
using Server::ClientAddressFromForwarded;
Check(ClientAddressFromForwarded("203.0.113.7") == "203.0.113.7",
"forwarded: single entry");
Check(ClientAddressFromForwarded("198.51.100.4, 203.0.113.7") == "203.0.113.7",
"forwarded: rightmost entry wins");
// The attack this exists to defeat: a client that sends its own header
// to look like a different peer. Caddy appends the truth on the right.
Check(ClientAddressFromForwarded("1.1.1.1, 2.2.2.2, 203.0.113.7") == "203.0.113.7",
"forwarded: spoofed prefix ignored");
Check(ClientAddressFromForwarded("198.51.100.4, 203.0.113.7") == "203.0.113.7",
"forwarded: padding trimmed");
Check(ClientAddressFromForwarded("2001:db8::1") == "2001:db8::1",
"forwarded: ipv6 passes through");
Check(ClientAddressFromForwarded("").empty(), "forwarded: empty stays empty");
// No header at all means nothing proxied this request; the caller must
// see an empty peer and fall back to the global budget.
Check(ClientAddressFromForwarded("198.51.100.4, ").empty(),
"forwarded: empty last entry is no peer");
}
{
using Server::OriginAllowed;
Check(OriginAllowed("https://catcrafts.net", "https://catcrafts.net"),
"origin: same origin allowed");
Check(OriginAllowed("https://catcrafts.net", "https://catcrafts.net/"),
"origin: trailing slash on the base normalised");
// A non-browser client (curl, the e2e suite) sends no Origin and
// cannot be a cross-site forgery — there is no session to ride on.
Check(OriginAllowed("", "https://catcrafts.net"), "origin: absent allowed");
Check(!OriginAllowed("https://evil.example", "https://catcrafts.net"),
"origin: foreign origin refused");
// Neither a subdomain nor a lookalike is us.
Check(!OriginAllowed("https://catcrafts.net.evil.example", "https://catcrafts.net"),
"origin: suffix lookalike refused");
Check(!OriginAllowed("https://shop.catcrafts.net", "https://catcrafts.net"),
"origin: subdomain refused");
// Scheme is part of an origin: http is not https.
Check(!OriginAllowed("http://catcrafts.net", "https://catcrafts.net"),
"origin: scheme mismatch refused");
// A sandboxed iframe posts Origin: null. Present, and not us.
Check(!OriginAllowed("null", "https://catcrafts.net"), "origin: null refused");
Check(!OriginAllowed("https://catcrafts.net", ""),
"origin: unconfigured base refuses rather than accepts all");
// dev.sh serves on localhost and sets --redirect-base to match.
Check(OriginAllowed("http://localhost:8080", "http://localhost:8080"),
"origin: dev localhost base matches");
}
if (failures != 0) {
std::println(std::cerr, "{} check(s) failed", failures);
return 1;
}
return 0;
}

View file

@ -0,0 +1,49 @@
/*
catcrafts.net
Copyright (C) 2026 Catcrafts
The source code of this website is made available for viewing purposes only.
No permission is granted to copy, modify, distribute, or create derivative works.
*/
// Order tokens and the human-facing references derived from them. The token
// is the only credential an order page has, so its shape check is a security
// boundary: anything that is not exactly 32 lowercase hex characters must
// never reach a lookup.
import std;
import Catcrafts.Shared;
import Catcrafts.Server;
using namespace Catcrafts;
namespace {
int failures = 0;
void Check(bool ok, std::string_view what, std::string_view got = {}) {
if (ok) return;
++failures;
std::println(std::cerr, "FAIL: {}{}{}", what,
got.empty() ? "" : " got: ", got);
}
} // namespace
int main() {
Check(IsOrderToken("0123456789abcdef0123456789abcdef"), "token: valid shape");
Check(!IsOrderToken("0123456789ABCDEF0123456789ABCDEF"), "token: uppercase rejected");
Check(!IsOrderToken("0123456789abcdef0123456789abcde"), "token: short rejected");
Check(!IsOrderToken("0123456789abcdef0123456789abcdeg"), "token: non-hex rejected");
const std::string tok = Server::NewOrderToken();
Check(IsOrderToken(tok), "token: generator emits valid tokens", tok);
Check(Server::NewOrderToken() != tok, "token: not constant");
Check(Server::ReferenceFromToken("abcdef0123456789abcdef0123456789") == "CC-ABCDEF",
"reference: derived and uppercased");
if (failures != 0) {
std::println(std::cerr, "{} check(s) failed", failures);
return 1;
}
return 0;
}

View file

@ -0,0 +1,93 @@
/*
catcrafts.net
Copyright (C) 2026 Catcrafts
The source code of this website is made available for viewing purposes only.
No permission is granted to copy, modify, distribute, or create derivative works.
*/
// The EURC rail's two parsers.
//
// The balance decoder is the line between "the buyer paid" and "the node
// errored", and the chains parser is the line between "watching a chain"
// and "silently not watching it". Both refuse rather than guess.
import std;
import Catcrafts.Shared;
import Catcrafts.Server;
using namespace Catcrafts;
namespace {
int failures = 0;
void Check(bool ok, std::string_view what, std::string_view got = {}) {
if (ok) return;
++failures;
std::println(std::cerr, "FAIL: {}{}{}", what,
got.empty() ? "" : " got: ", got);
}
} // namespace
int main() {
using Server::ParseEthCallUint;
Check(ParseEthCallUint(R"({"jsonrpc":"2.0","id":1,"result":"0x0"})") == 0,
"eurc: zero balance");
Check(ParseEthCallUint(
R"({"jsonrpc":"2.0","id":1,"result":"0x0000000000000000000000000000000000000000000000000000000022001230"})")
== 570430000,
"eurc: €570.43 as 6-decimal base units, full 32-byte word");
Check(ParseEthCallUint(R"({"result":"0xFF"})") == 255,
"eurc: uppercase hex accepted");
// 2^63 does not fit; the decoder must saturate, never wrap to negative.
Check(ParseEthCallUint(R"({"result":"0x8000000000000000"})")
== std::numeric_limits<std::int64_t>::max(),
"eurc: overflow saturates");
Check(!ParseEthCallUint(
R"({"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"x"}})")
.has_value(),
"eurc: an RPC error is not a zero balance");
Check(!ParseEthCallUint(R"({"result":"21ff361c"})").has_value(),
"eurc: missing 0x rejected");
Check(!ParseEthCallUint(R"({"result":"0x"})").has_value(),
"eurc: empty word rejected");
Check(!ParseEthCallUint(R"({"result":42})").has_value(),
"eurc: numeric result rejected — the wire type is a hex string");
Check(!ParseEthCallUint("garbage").has_value(), "eurc: malformed payload");
const auto chains = Server::ParseEurcChains(R"({"chains":[
{"name":"base","rpc":"https://mainnet.base.org",
"contract":"0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42",
"chain_id":8453,"note":"lowest fees"},
{"name":"ethereum","rpc":"https://ethereum-rpc.publicnode.com",
"contract":"0x1aBaEA1f7C830bD89Acc67eC4af516284b1bC33c","chain_id":1}]})");
Check(chains.has_value() && chains->size() == 2, "eurc: chains file parses");
if (chains && chains->size() == 2) {
Check((*chains)[0].name == "base" && (*chains)[0].chainId == 8453,
"eurc: file order preserved — first entry is the recommendation");
Check((*chains)[0].contract == "0x60a3e35cc302bfa44cb288bc5a4f316fdb1adb42",
"eurc: contract lowercased for comparison");
Check((*chains)[0].note == "lowest fees", "eurc: note carried");
Check((*chains)[1].blockTag == "finalized" && (*chains)[1].decimals == 6,
"eurc: defaults");
}
Check(!Server::ParseEurcChains(R"({"chains":[
{"name":"base","rpc":"https://x.org","contract":"0xdeadbeef"}]})").has_value(),
"eurc: short contract rejects the whole file");
Check(!Server::ParseEurcChains(R"({"chains":[
{"name":"base","rpc":"ftp://x.org",
"contract":"0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42"}]})").has_value(),
"eurc: non-http rpc rejects the whole file");
Check(!Server::ParseEurcChains(R"({"chains":[]})").has_value(),
"eurc: empty chain list rejected");
Check(!Server::ParseEurcChains("garbage").has_value(),
"eurc: malformed chains file rejected");
if (failures != 0) {
std::println(std::cerr, "{} check(s) failed", failures);
return 1;
}
return 0;
}

View file

@ -0,0 +1,116 @@
/*
catcrafts.net
Copyright (C) 2026 Catcrafts
The source code of this website is made available for viewing purposes only.
No permission is granted to copy, modify, distribute, or create derivative works.
*/
// The JSON reader. It parses provider payloads and fetched post lists —
// input from other people's servers — so the malformed-input half of this
// suite is the part that matters most: reject, never partially accept.
import std;
import Catcrafts.Shared;
using namespace Catcrafts;
namespace {
int failures = 0;
void Check(bool ok, std::string_view what, std::string_view got = {}) {
if (ok) return;
++failures;
std::println(std::cerr, "FAIL: {}{}{}", what,
got.empty() ? "" : " got: ", got);
}
} // namespace
int main() {
using namespace Catcrafts::Json;
auto ok = [](std::string_view text) { return Parse(text).has_value(); };
auto bad = [](std::string_view text) { return !Parse(text).has_value(); };
// ── shapes ────────────────────────────────────────────────────────
Check(ok("{}"), "json: empty object");
Check(ok("[]"), "json: empty array");
Check(ok(" \n\t {\"a\": 1} \n "), "json: surrounding whitespace");
Check(ok("[1,2,3]"), "json: number array");
Check(ok("{\"a\":{\"b\":[true,false,null]}}"), "json: nesting");
// ── malformed input must be rejected, not partially accepted ──────
Check(bad("{"), "json: unterminated object");
Check(bad("[1,]"), "json: trailing comma");
Check(bad("{\"a\":1,}"), "json: trailing comma in object");
Check(bad("{'a':1}"), "json: single quotes");
Check(bad("\"unterminated"), "json: unterminated string");
Check(bad("{\"a\" 1}"), "json: missing colon");
Check(bad("nul"), "json: bad literal");
Check(bad("{} garbage"), "json: trailing content rejected");
Check(bad("[1,2] [3]"), "json: concatenated documents rejected");
Check(bad("\"raw\nnewline\""), "json: control char in string");
Check(bad("01"), "json: leading zero");
Check(bad("+1"), "json: leading plus");
Check(bad("1."), "json: trailing decimal point");
Check(bad(".5"), "json: bare fraction");
Check(bad("1e"), "json: empty exponent");
Check(bad("1e+"), "json: exponent sign with no digits");
Check(bad("-"), "json: lone minus");
Check(bad("1e400"), "json: out of double range");
Check(ok("0"), "json: zero");
Check(ok("-0"), "json: negative zero");
Check(ok("0.5"), "json: leading zero with fraction");
Check(ok("-1.5e-3"), "json: full number grammar");
Check(ok("1E+2"), "json: capital exponent");
Check(bad(""), "json: empty input");
// ── string decoding ───────────────────────────────────────────────
auto strOf = [](std::string_view doc) -> std::string {
auto v = Parse(doc);
if (!v || !v->IsObject()) return "<parse-failed>";
return std::string(v->Str("k"));
};
Check(strOf(R"({"k":"a\"b"})") == "a\"b", "json: escaped quote");
Check(strOf(R"({"k":"a\\b"})") == "a\\b", "json: escaped backslash");
Check(strOf(R"({"k":"a\nb"})") == "a\nb", "json: newline escape");
Check(strOf(R"({"k":"A"})") == "A", "json: \\u ascii");
Check(strOf(R"({"k":"é"})") == "é", "json: \\u latin-1");
Check(strOf(R"({"k":""})") == "", "json: \\u BMP");
// Astral plane arrives as a UTF-16 surrogate pair. Encoding each half
// separately yields invalid UTF-8 — emoji in Lemmy post titles are
// exactly this case, so it has to be combined.
Check(strOf(R"({"k":"😺"})") == "\U0001F63A", "json: surrogate pair -> emoji");
Check(strOf(R"({"k":"\ud83d"})") == "<EFBFBD>", "json: lone high surrogate -> U+FFFD");
Check(strOf(R"({"k":"\ude3a"})") == "<EFBFBD>", "json: lone low surrogate -> U+FFFD");
Check(strOf(R"({"k":"raw é "})") == "raw é ✓", "json: raw utf-8 passthrough");
// ── accessors ─────────────────────────────────────────────────────
auto doc = Parse(R"({"s":"x","n":42,"neg":-7,"b":true,"nul":null})");
Check(doc.has_value(), "json: accessor doc parses");
if (doc) {
Check(doc->Str("s") == "x", "json: Str");
Check(doc->Int("n") == 42, "json: Int");
Check(doc->Int("neg") == -7, "json: Int negative");
Check(doc->Bool("b"), "json: Bool");
Check(doc->Str("missing", "fallback") == "fallback", "json: Str fallback");
Check(doc->Int("missing", 99) == 99, "json: Int fallback");
// Wrong-typed field falls back rather than reinterpreting.
Check(doc->Int("s", 5) == 5, "json: type mismatch falls back");
Check(doc->Find("missing") == nullptr, "json: Find absent");
Check(doc->Find("nul") != nullptr && doc->Find("nul")->IsNull(),
"json: present-null distinguishable from absent");
}
// ── depth guard ───────────────────────────────────────────────────
std::string deep(200, '[');
Check(bad(deep), "json: deep nesting rejected, not stack overflow");
if (failures != 0) {
std::println(std::cerr, "{} check(s) failed", failures);
return 1;
}
return 0;
}

View file

@ -0,0 +1,82 @@
/*
catcrafts.net
Copyright (C) 2026 Catcrafts
The source code of this website is made available for viewing purposes only.
No permission is granted to copy, modify, distribute, or create derivative works.
*/
// The wire-amount parser (Mollie quotes amounts as strings) and the Mollie
// payment parser — the line between "the buyer paid" and "the provider said
// something we did not understand". Both refuse rather than guess.
import std;
import Catcrafts.Shared;
import Catcrafts.Server;
using namespace Catcrafts;
namespace {
int failures = 0;
void Check(bool ok, std::string_view what, std::string_view got = {}) {
if (ok) return;
++failures;
std::println(std::cerr, "FAIL: {}{}{}", what,
got.empty() ? "" : " got: ", got);
}
} // namespace
int main() {
// ── the wire-amount parser ────────────────────────────────────────
using Server::ParseAmountToMinor;
Check(ParseAmountToMinor("614.00") == 61400, "amount: normal");
Check(ParseAmountToMinor("614") == 61400, "amount: no fraction");
Check(ParseAmountToMinor("614.5") == 61450, "amount: one fraction digit");
Check(ParseAmountToMinor("0.01") == 1, "amount: one cent");
Check(!ParseAmountToMinor("614.005").has_value(), "amount: three decimals rejected");
Check(!ParseAmountToMinor("-1.00").has_value(), "amount: negative rejected");
Check(!ParseAmountToMinor("+1.00").has_value(), "amount: sign rejected");
Check(!ParseAmountToMinor("1e3").has_value(), "amount: exponent rejected");
Check(!ParseAmountToMinor("1.").has_value(), "amount: trailing dot rejected");
Check(!ParseAmountToMinor(".5").has_value(), "amount: bare fraction rejected");
Check(!ParseAmountToMinor("").has_value(), "amount: empty rejected");
Check(!ParseAmountToMinor("1 000.00").has_value(), "amount: separator rejected");
// ── the Mollie payment parser ─────────────────────────────────────
{
const auto p1 = Server::ParseMolliePayment(R"({
"resource":"payment","id":"tr_7UhSN1zuXS","status":"open","method":null,
"amount":{"value":"578.30","currency":"EUR"},
"_links":{"checkout":{"href":"https://www.mollie.com/checkout/select-method/7UhSN1zuXS","type":"text/html"}}})");
Check(p1.has_value(), "mollie: open payment parses");
if (p1) {
Check(p1->id == "tr_7UhSN1zuXS", "mollie: id");
Check(p1->status == "open", "mollie: status");
Check(p1->amountMinor == 57830, "mollie: amount to cents");
Check(p1->checkoutUrl == "https://www.mollie.com/checkout/select-method/7UhSN1zuXS",
"mollie: checkout link");
Check(p1->method.empty(), "mollie: null method is empty");
}
const auto p2 = Server::ParseMolliePayment(R"({
"id":"tr_x","status":"paid","method":"ideal",
"amount":{"value":"578.30","currency":"EUR"},"_links":{}})");
Check(p2 && p2->status == "paid" && p2->method == "ideal",
"mollie: paid payment carries the method");
const auto p3 = Server::ParseMolliePayment(R"({
"id":"tr_y","status":"paid","amount":{"value":"578.30","currency":"USD"}})");
Check(p3 && p3->amountMinor == 0, "mollie: non-EUR amount refuses to count");
Check(!Server::ParseMolliePayment("garbage").has_value(),
"mollie: malformed payload rejected");
Check(!Server::ParseMolliePayment(R"({"status":"open"})").has_value(),
"mollie: missing id rejected");
}
if (failures != 0) {
std::println(std::cerr, "{} check(s) failed", failures);
return 1;
}
return 0;
}

View file

@ -0,0 +1,102 @@
/*
catcrafts.net
Copyright (C) 2026 Catcrafts
The source code of this website is made available for viewing purposes only.
No permission is granted to copy, modify, distribute, or create derivative works.
*/
// The Sendcloud response parser. Weights are the kilogram strings the API
// sends; every Find() below asks for a parcel weight, because a price without
// a weight is not a thing this table has any more.
import std;
import Catcrafts.Shared;
import Catcrafts.Server;
using namespace Catcrafts;
namespace {
int failures = 0;
void Check(bool ok, std::string_view what, std::string_view got = {}) {
if (ok) return;
++failures;
std::println(std::cerr, "FAIL: {}{}{}", what,
got.empty() ? "" : " got: ", got);
}
} // namespace
int main() {
const auto table = Server::ParseSendcloudMethods(R"({"shipping_methods":[
{"name":"Other Method","min_weight":"0.001","max_weight":"10.000",
"countries":[{"iso_2":"NL","price":1.00}]},
{"name":"DHL For You Home","min_weight":"0.001","max_weight":"2.000",
"countries":[
{"iso_2":"NL","price":6.25},
{"iso_2":"DE","price":8.20},
{"iso_2":"CA","price":42.50},
{"iso_2":"XX","price":0},
{"iso_2":"TOOLONG","price":5.00}]},
{"name":"DHL For You Home","min_weight":"2.000","max_weight":"10.000",
"countries":[{"iso_2":"NL","price":9.95},{"iso_2":"DE","price":13.40}]},
{"name":"DHL For You Home","countries":[{"iso_2":"BE","price":1.00}]}]})",
"DHL For You");
Check(table.method == "DHL For You Home", "sendcloud: method matched by substring");
Check(table.Find("NL", 700) == 625, "sendcloud: NL price to cents");
Check(table.Find("DE", 700) == 820, "sendcloud: 8.20 rounds exactly");
Check(table.Find("CA", 700) == 4250, "sendcloud: CA price");
Check(table.Find("XX", 700) == 0, "sendcloud: zero price dropped");
Check(table.Find("TOOLONG", 700) == 0, "sendcloud: malformed iso dropped");
// The bug the old parser had: it stopped at the FIRST matching method,
// so every parcel was priced at whichever band came first and the
// heavier bands were invisible.
Check(table.Find("NL", 2100) == 995 && table.Find("DE", 2100) == 1340,
"sendcloud: every weight band of a matched method is kept");
Check(table.Find("NL", 11000) == 0,
"sendcloud: past the heaviest band there is no price");
Check(table.Find("BE", 700) == 0,
"sendcloud: a method with no weight range is unusable, not unlimited");
Check(Server::ParseSendcloudMethods("garbage", "x").perCountry.empty(),
"sendcloud: malformed payload yields nothing");
// Comma-separated merge: courier for Europe, post for the world; the
// earlier FILTER keeps any country both cover — including that
// country's heavier bands, which must not leak in from the later one.
const auto merged = Server::ParseSendcloudMethods(R"({"shipping_methods":[
{"name":"DPD Home","min_weight":"0.001","max_weight":"10.000","countries":[
{"iso_2":"NL","price":7.13},{"iso_2":"DE","price":10.49}]},
{"name":"PostNL Parcels non-EU","min_weight":"0.001","max_weight":"2.000",
"countries":[
{"iso_2":"CA","price":23.95},{"iso_2":"US","price":17.94},
{"iso_2":"DE","price":99.99}]},
{"name":"PostNL Parcels non-EU","min_weight":"2.000","max_weight":"20.000",
"countries":[{"iso_2":"CA","price":48.10},{"iso_2":"DE","price":99.99}]}]})",
"DPD Home, PostNL Parcels non-EU");
Check(merged.Find("NL", 700) == 713 && merged.Find("CA", 700) == 2395,
"sendcloud: merged table covers both filters");
Check(merged.Find("CA", 5000) == 4810, "sendcloud: heavier band from the later filter");
Check(merged.Find("DE", 700) == 1049,
"sendcloud: earlier filter wins a shared country");
Check(merged.Find("DE", 12000) == 0,
"sendcloud: and owns it outright — no band from the loser");
Check(merged.method == "DPD Home + PostNL Parcels non-EU",
"sendcloud: merged method names recorded, deduplicated per band");
// Two services under one filter publishing the same ceiling: the
// cheaper is the only sensible quote, since both carry the parcel.
const auto dup = Server::ParseSendcloudMethods(R"({"shipping_methods":[
{"name":"DPD Home","min_weight":"0.001","max_weight":"10.000",
"countries":[{"iso_2":"NL","price":9.00}]},
{"name":"DPD Home Signed","min_weight":"0.001","max_weight":"10.000",
"countries":[{"iso_2":"NL","price":7.50}]}]})", "DPD Home");
Check(dup.Find("NL", 700) == 750, "sendcloud: duplicate band keeps the cheaper");
if (failures != 0) {
std::println(std::cerr, "{} check(s) failed", failures);
return 1;
}
return 0;
}

View file

@ -0,0 +1,566 @@
/*
catcrafts.net
Copyright (C) 2026 Catcrafts
The source code of this website is made available for viewing purposes only.
No permission is granted to copy, modify, distribute, or create derivative works.
*/
// The whole order lifecycle against the real binary: checkout, the payment
// choice, export pricing, the refusals, the fake rail settling, the signed
// invoice, the confirmation email, and the financials page agreeing with the
// ledger. The fake payment rail makes this testable: it hands out pretend
// payment links and reports "paid" once the marker file exists — which is how
// this suite simulates the customer paying.
//
// The lifecycle half runs only while the shop is OPEN; while coming-soon it
// asserts the closed behaviour instead. Launch day (status flip to
// "available" in Catcrafts.Shared-Content.cppm) re-arms the full suite with
// no edit here. The validation half runs in both states on purpose:
// validation happens before the coming-soon check, and that ordering is
// exactly what it pins.
import std;
import Catcrafts.Shared;
import Crafter.Network;
import Catcrafts.E2eHarness;
using namespace Catcrafts;
using namespace Catcrafts::E2e;
namespace {
constexpr std::string_view kGood =
"email=e2e%40example.org&name=Ada%20Lovelace&street=Main%20St%201&postal=1234AB&city=Delft&country=nl";
std::string Good(std::string_view suffix = {}) {
return std::string(kGood) + std::string(suffix);
}
// The order token from a checkout redirect's Location header.
std::string TokenOf(const Crafter::HTTPResponse& r) {
const auto it = r.headers.find("location");
if (it == r.headers.end()) return {};
std::smatch m;
if (std::regex_search(it->second, m, std::regex(R"(/order/([0-9a-f]{32})$)"))) {
return m[1].str();
}
return {};
}
// The ledger is JSONL — one event per line.
std::vector<std::string> LedgerLines(TestServer& srv) {
std::vector<std::string> lines;
for (auto part : std::views::split(srv.OrdersText(), '\n')) {
std::string_view line(part.begin(), part.end());
if (!line.empty()) lines.emplace_back(line);
}
return lines;
}
std::size_t MailCount(TestServer& srv) {
std::size_t n = 0;
for (const auto& entry : std::filesystem::directory_iterator(srv.Work())) {
const std::string name = entry.path().filename().string();
if (name.starts_with("mail-") && name.ends_with(".eml")) ++n;
}
return n;
}
// Wait until `predicate` holds, on the reconciler/mailer cadence.
bool SettleUntil(std::function<bool()> predicate, std::int32_t tries = 40) {
for (std::int32_t i = 0; i < tries; ++i) {
if (predicate()) return true;
std::this_thread::sleep_for(std::chrono::milliseconds(250));
}
return predicate();
}
bool GpgVerifies(const std::filesystem::path& file) {
return std::system(std::format("gpg --verify {} >/dev/null 2>&1",
file.string()).c_str()) == 0;
}
void OpenShopLifecycle(TestServer& srv) {
// ── 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.
const auto checkout = srv.Post("/shop/fp6-pmos", Good());
const std::string token = TokenOf(checkout);
Check(checkout.status == "303" && !token.empty(),
"POST checkout -> 303 straight to payment", checkout.status);
{
const std::string ledger = srv.OrdersText();
Check(ledger.find("\"country\":\"NL\"") != std::string::npos
&& ledger.find("\"total_minor\":57830") != std::string::npos,
"order stored: NL total is €578.30 (green €563.30 + €15 shipping)");
// No `pay` field in that submission, which is what a form with only
// one rail configured posts: it must land on the bank rail rather
// than nothing.
Check(ledger.find("\"pay_choice\":\"bank\"") != std::string::npos,
"a submission with no payment choice records bank");
}
// The order page: awaiting payment, pay link, reference, self-refreshing,
// never indexed, never cached.
const std::string orderPath = std::format("/order/{}", token);
{
const std::string page = srv.Body(orderPath);
for (std::string_view probe : { "awaiting payment", "Resume payment", "CC-",
"http-equiv=\"refresh\"", "€578.30" }) {
Check(page.find(probe) != std::string::npos,
std::format("order page has {}", probe));
}
}
srv.HeaderHas(orderPath, "x-robots-tag", "noindex", "order page is noindex");
srv.HeaderHas(orderPath, "cache-control", "no-store", "order page is never cached");
// Unknown and malformed tokens are the same 404.
srv.CheckStatus("/order/00000000000000000000000000000000", "404");
srv.CheckStatus("/order/not-a-token", "404");
srv.CheckStatus("/order/deadbeef", "404");
// ── the payment choice ────────────────────────────────────────────
// Both slots are configured here, so the form must offer both and the
// picked one must survive all the way into the ledger. The ledger is the
// assertion that matters: it is what the reconciler later reads to decide
// WHICH provider may confirm the order, so a choice that renders but is
// not stored would mean crypto orders being asked about at Mollie.
srv.BodyHas("/shop/fp6-pmos", "name=\"pay\"", "the form offers a payment choice");
srv.BodyHas("/shop/fp6-pmos", "value=\"crypto\"", "crypto is one of the choices");
srv.BodyHas("/shop/fp6-pmos", "value=\"bank\" checked", "bank is the pre-selected choice");
const std::string tokenCrypto = TokenOf(srv.Post("/shop/fp6-pmos", Good("&pay=crypto")));
Check(!tokenCrypto.empty(), "a crypto order goes through");
if (!tokenCrypto.empty()) {
bool recorded = false;
for (const std::string& line : LedgerLines(srv)) {
if (line.find(std::format("\"id\":\"{}\"", tokenCrypto)) != std::string::npos) {
recorded = recorded
|| line.find("\"pay_choice\":\"crypto\"") != std::string::npos;
}
}
Check(recorded, "the crypto choice is what the ledger records");
// The order page has to promise what is actually behind the button —
// the bank copy on a crypto order would send someone looking for
// iDEAL. (The shell suite pinned 'Lightning' here, CoinGate-era copy
// that had already left the codebase — this is the check that caught
// the drift when the port first ran against an open shop.)
const std::string page = srv.Body(std::format("/order/{}", tokenCrypto));
Check(page.find("completes your crypto payment") != std::string::npos,
"the crypto order page describes the crypto payment");
Check(page.find("iDEAL") == std::string::npos,
"the crypto order page does not promise iDEAL");
}
// A payment method nobody offers is refused, and refused as a FIELD error
// so the form comes back with the choice highlighted rather than a bare
// 400.
{
const auto bogus = srv.Post("/shop/fp6-pmos", Good("&pay=invoice-me-later"));
Check(bogus.status == "422", "an unknown payment method is refused", bogus.status);
Check(bogus.body.find("Pick one of the payment methods") != std::string::npos,
"the refusal names the payment field");
}
// A non-EU order: ex-VAT goods, world shipping, and the indicative
// national currency line sourced from the build-time ECB rates. GB rather
// than a North American destination because those are refused outright.
const std::string tokenGb = TokenOf(srv.Post("/shop/fp6-pmos",
"email=gb%40example.org&name=Terry&street=1%20Baker%20St&postal=W1U&city=London&country=GB"));
Check(!tokenGb.empty(), "GB checkout issues an order");
if (!tokenGb.empty()) {
const std::string page = srv.Body(std::format("/order/{}", tokenGb));
// €465.54 goods (green net) + €55 world shipping = €520.54
Check(page.find("€520.54") != std::string::npos,
"export order total is ex-VAT + world shipping");
Check(page.find("Zero-rated export") != std::string::npos,
"export order states the VAT treatment");
Check(std::regex_search(page, std::regex("≈ £[0-9]+")),
"export order shows the indicative GBP amount");
Check(page.find("indicative") != std::string::npos,
"conversion is labelled indicative");
}
// 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.
const std::string tokenWhite = TokenOf(srv.Post("/shop/fp6-pmos",
"email=w%40example.org&name=W&street=X%201&postal=1&city=Y&country=GB&color=white&quantity=2"));
Check(!tokenWhite.empty(), "white ×2 checkout issues an order");
if (!tokenWhite.empty()) {
const std::string page = srv.Body(std::format("/order/{}", tokenWhite));
Check(page.find("€1137.45") != std::string::npos,
"white ×2 export total nets the line, not the unit");
Check(page.find("Device × 2") != std::string::npos, "order page shows the quantity");
Check(page.find("White") != std::string::npos, "order page names the colour");
}
// A colour we never listed must not buy anything, whatever the form claims.
srv.CheckStatus("/shop/fp6-pmos", "422", "POST", Good("&color=mauve"));
srv.CheckStatus("/shop/fp6-pmos", "422", "POST", Good("&quantity=100"));
srv.CheckStatus("/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.
Check(!TokenOf(srv.Post("/shop/fp6-pmos", Good("&quantity=9"))).empty(),
"a nine-unit order goes through");
srv.BodyHas("/shop/fp6-pmos", "type=\"number\"", "quantity is a number input, not a dropdown");
// The ceiling is physical: the heaviest band any destination offers
// (10 kg in the fixture) divided by the boxed unit weight (700 g) = 14.
// The input advertises the BEST case across destinations; the per-country
// limit is enforced on submit, below.
srv.BodyHas("/shop/fp6-pmos", "max=\"14\"", "quantity input carries the one-parcel ceiling");
// One order is one parcel. Fifteen units is 10.5 kg, past every band the
// fixture has, so it must be refused rather than quoted a rate the
// carrier would not accept — and the refusal has to say what WOULD fit,
// or the buyer is left guessing.
{
const auto heavy = srv.Post("/shop/fp6-pmos", Good("&quantity=15"));
Check(heavy.status == "422", "an over-weight order is refused", heavy.status);
Check(heavy.body.find("up to 14 per order") != std::string::npos,
"the too-heavy refusal says what fits");
Check(heavy.body.find("orders@catcrafts.net") != std::string::npos,
"the too-heavy refusal offers a way to order anyway");
}
// A destination the carrier has no rate for. Since the zone fallback went
// away there is no price to invent, so this is a refusal — and
// specifically NOT the no-sale refusal, which is a different (policy)
// reason with different wording.
constexpr std::string_view kAu =
"email=au%40example.org&name=Alex&street=1%20George%20St&postal=2000&city=Sydney&country=AU";
{
const auto au = srv.Post("/shop/fp6-pmos", std::string(kAu));
Check(au.status == "422", "an uncovered destination is refused", au.status);
Check(au.body.find("No carrier rate for AU") != std::string::npos,
"an uncovered destination is refused, naming the country");
// The country FIELD ERROR must be the carrier message, not the
// no-sale one. Matched on the error markup rather than the bare
// sentence: the no-sale line is standing copy above every buy form.
Check(au.body.find("field__error\">Catcrafts does not sell") == std::string::npos,
"an uncovered destination is not confused with a refused one");
const std::size_t before = LedgerLines(srv).size();
srv.Post("/shop/fp6-pmos", std::string(kAu));
Check(LedgerLines(srv).size() == before, "a refused destination writes no order");
}
// No invoice exists before the money does — awaiting orders answer 404.
srv.CheckStatus(std::format("/order/{}/invoice.md", token), "404");
srv.CheckStatus("/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. The paid state
// shows the confirmation notice, deliberately WITHOUT a second "paid"
// badge — so the success marker is the notice text.
WriteFile(std::filesystem::path(srv.Orders().string() + ".fake-paid"), "");
{
const std::string page = srv.WaitForBody(orderPath, "order is confirmed");
Check(page.find("order is confirmed") != std::string::npos,
"order confirms after payment (arrival poll or reconciler)");
const std::size_t badges = CountOccurrences(page, "badge--active");
Check(badges == 0, "no duplicate paid badge next to the confirmation",
std::format("found {} active badges", badges));
Check(page.find("http-equiv=\"refresh\"") == std::string::npos,
"paid order page stops self-refreshing");
}
{
const std::string ledger = srv.OrdersText();
Check(ledger.find("\"type\":\"status\"") != std::string::npos
&& ledger.find("\"status\":\"paid\"") != std::string::npos,
"paid transition is an appended event, not a rewrite");
// The paid event records HOW it was paid — card money stays
// reversible for months, so the ledger must show which orders carry
// that tail.
Check(ledger.find("\"via\":\"fake\"") != std::string::npos,
"paid event records the payment method");
}
// ── the signed invoice ────────────────────────────────────────────
// Paid orders download a clearsigned markdown invoice: sequential number,
// registered identity, amounts — and a signature that verifies offline.
{
const auto invoice = srv.Get(std::format("/order/{}/invoice.md", token));
for (std::string_view probe : { "BEGIN PGP SIGNED MESSAGE", "# Invoice ",
"Customer number: ", "Chico Mendesring 256",
"KVK 78437059", "NL003329281B38", "CC-",
"VAT 21% (NL)", "€578.30" }) {
Check(invoice.body.find(probe) != std::string::npos,
std::format("invoice has {}", probe));
}
const auto disposition = invoice.headers.find("content-disposition");
Check(disposition != invoice.headers.end()
&& disposition->second.find("attachment") != std::string::npos,
"invoice downloads as an attachment");
const std::filesystem::path file = srv.Work() / "invoice.md";
WriteFile(file, invoice.body);
Check(GpgVerifies(file), "invoice signature verifies with gpg");
}
// Several 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.
SettleUntil([&] {
return CountOccurrences(srv.OrdersText(), "\"type\":\"invoice\"") >= 4;
});
// 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.
{
const std::string ledger = srv.OrdersText();
const std::size_t invoices = CountOccurrences(ledger, "\"type\":\"invoice\"");
std::set<std::string> numbers;
bool uuidSeries = false;
const std::regex numberField(R"lit("number":"([0-9a-f-]*)")lit");
const std::regex uuidSeq(
R"(^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12}-[0-9]+$)");
for (auto it = std::sregex_iterator(ledger.begin(), ledger.end(), numberField);
it != std::sregex_iterator(); ++it) {
numbers.insert((*it)[1].str());
uuidSeries = uuidSeries || std::regex_match((*it)[1].str(), uuidSeq);
}
Check(invoices > 0 && invoices == numbers.size(),
std::format("invoice numbers are unique ({} issued)", invoices));
Check(uuidSeries, "invoice numbers are customer-uuid series");
// 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.
std::set<std::string> customers;
const std::regex customerField(R"lit("customer":"([0-9a-f-]*)")lit");
for (auto it = std::sregex_iterator(ledger.begin(), ledger.end(), customerField);
it != std::sregex_iterator(); ++it) {
customers.insert((*it)[1].str());
}
Check(customers.size() < invoices,
std::format("repeat customer shares one series ({} customers, {} invoices)",
customers.size(), invoices));
}
// ── the confirmation email ────────────────────────────────────────
// Every paid order gets exactly one confirmation with the signed invoice
// attached. The expected count comes from the LEDGER rather than a number
// written here: "one per paid order" is the actual property, and a
// literal would have to be edited by anyone who adds an order above — a
// test that fails for the wrong reason and gets bumped without being
// read. The mailer sweeps every 2 s.
const std::size_t paidCount = CountOccurrences(srv.OrdersText(), "\"status\":\"paid\"");
SettleUntil([&] { return MailCount(srv) >= paidCount; }, 60);
const std::size_t mailCount = MailCount(srv);
Check(mailCount == paidCount,
std::format("one confirmation email per paid order ({} sent)", mailCount),
std::format("expected {}", paidCount));
// The NL order's message, found by its own order link (the same email
// address placed two orders, so the address alone would be ambiguous).
std::string nlMail;
std::string gbMail;
for (const auto& entry : std::filesystem::directory_iterator(srv.Work())) {
const std::string name = entry.path().filename().string();
if (!name.starts_with("mail-") || !name.ends_with(".eml")) continue;
const std::string mail = ReadFile(entry.path());
if (mail.find(std::format("/order/{}", token)) != std::string::npos) nlMail = mail;
if (!tokenGb.empty()
&& mail.find(std::format("/order/{}", tokenGb)) != std::string::npos) {
gbMail = mail;
}
}
Check(!nlMail.empty(), "a confirmation email links the NL order");
if (!nlMail.empty()) {
for (std::string_view probe : { "To: e2e@example.org", "Subject: Catcrafts order CC-",
"From: Catcrafts <info@catcrafts.net>",
"MIME-Version: 1.0", "€578.30", "incl. 21% NL VAT",
"KVK 78437059", "BEGIN PGP SIGNED MESSAGE",
"filename=\"catcrafts-invoice-" }) {
Check(nlMail.find(probe) != std::string::npos,
std::format("email has {}", probe));
}
// The attached invoice must verify offline exactly like the download.
const std::size_t begin = nlMail.find("-----BEGIN PGP SIGNED MESSAGE-----");
const std::size_t end = nlMail.find("-----END PGP SIGNATURE-----");
Check(begin != std::string::npos && end != std::string::npos,
"email carries a full PGP block");
if (begin != std::string::npos && end != std::string::npos) {
const std::filesystem::path file = srv.Work() / "mail-invoice.asc";
WriteFile(file, nlMail.substr(
begin, end + std::string_view("-----END PGP SIGNATURE-----").size() - begin)
+ "\n");
Check(GpgVerifies(file), "emailed invoice signature verifies with gpg");
}
}
// The export order's message states the VAT treatment its invoice carries.
Check(!gbMail.empty() && gbMail.find("zero-rated export") != std::string::npos,
"export confirmation states the zero-rated treatment");
// Idempotency comes from the ledger's notified event, not from luck in
// timing — sit out two more sweeps and expect no extra message.
std::this_thread::sleep_for(std::chrono::seconds(5));
Check(MailCount(srv) == mailCount, "no order was emailed twice",
std::format("message count grew from {} to {}", mailCount, MailCount(srv)));
Check(srv.OrdersText().find("\"type\":\"notified\"") != std::string::npos,
"notified events recorded in the ledger");
// ── financials reflect the ledger ─────────────────────────────────
// Lifetime sales on /financials must equal the ledger: sum of total_minor
// over orders that have a paid status event. Derived from the ledger
// rather than written as a literal — same rule as the email count above:
// "the page equals the ledger" is the actual property.
{
std::set<std::string> paidIds;
std::int64_t wantMinor = 0;
std::size_t wantCount = 0;
const std::vector<std::string> lines = LedgerLines(srv);
for (const std::string& line : lines) {
const auto event = Json::Parse(line);
if (!event || !event->IsObject()) continue;
if (event->Str("type") == "status" && event->Str("status") == "paid") {
paidIds.insert(std::string(event->Str("id")));
}
}
for (const std::string& id : paidIds) {
for (const std::string& line : lines) {
const auto event = Json::Parse(line);
if (!event || !event->IsObject()) continue;
if (event->Str("type") == "order" && event->Str("id") == id) {
wantMinor += event->Int("total_minor");
++wantCount;
break;
}
}
}
const std::string page = srv.Body("/financials");
const std::string want = std::format(
"data-fin-sales-minor=\"{}\"", wantMinor);
Check(wantCount > 0 && page.find(want) != std::string::npos
&& page.find(std::format("data-fin-sales-count=\"{}\"", wantCount))
!= std::string::npos,
std::format("sales totals equal the ledger ({} orders, {} cents)",
wantCount, wantMinor));
// And the formatted euro figure for that total appears on the page.
const std::string euro = wantMinor % 100 == 0
? std::format("€{}", wantMinor / 100)
: std::format("€{}.{:02}", wantMinor / 100, wantMinor % 100);
Check(page.find(euro) != std::string::npos,
std::format("sales total renders as {}", euro));
}
}
void ComingSoon(TestServer& srv) {
// 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.
srv.CheckStatus("/shop/fp6-pmos", "409", "POST", Good());
Check(srv.OrdersText().empty(), "refused order writes nothing to the ledger");
std::println("shop is coming-soon; the checkout, order-lifecycle and invoice "
"checks re-arm when the status flips to available");
}
void AlwaysOnValidation(TestServer& srv) {
srv.CheckStatus("/shop/fp6-pmos", "422", "POST",
"name=Ada&street=x&postal=1&city=y&country=NL"); // no email
srv.CheckStatus("/shop/fp6-pmos", "422", "POST",
"email=nonsense&" + Good()); // bad email (dup keeps first)
srv.CheckStatus("/shop/fp6-pmos", "422", "POST",
"email=a%40b.example&country=NL"); // missing address
srv.CheckStatus("/shop/fp6-pmos", "422", "POST", Good("&website=spam")); // honeypot
// Destinations the shop refuses (Money::NoSaleCountries). Well-formed,
// real addresses: the refusal is policy, not a shape check, so it has to
// hold for every spelling the form accepts. Deliberately outside the
// shop-open gate — validation runs before the coming-soon check, so this
// must answer 422 whether the shop is open or not, and it is the
// assertion that would catch the block being lost in a refactor.
const std::size_t before = LedgerLines(srv).size();
srv.CheckStatus("/shop/fp6-pmos", "422", "POST",
"email=us%40example.org&name=Pat&street=1%20Main%20St&postal=43004&city=Columbus&country=US");
srv.CheckStatus("/shop/fp6-pmos", "422", "POST",
"email=ca%40example.org&name=Terry&street=1%20Bloor%20St&postal=M4W&city=Toronto&country=CA");
srv.CheckStatus("/shop/fp6-pmos", "422", "POST",
"email=us%40example.org&name=Pat&street=1%20Main%20St&postal=43004&city=Columbus&country=us");
// Refused in validation means nothing reached the ledger and no payment
// link was ever created.
Check(LedgerLines(srv).size() == before, "a refused destination creates no order record");
srv.CheckStatus("/shop/nope", "404", "POST", Good()); // unknown product
}
// The re-rendered form only exists when the shop is open; while coming-soon a
// rejection answers with the coming-soon page instead.
void RejectedFormEcho(TestServer& srv) {
// A rejected submission must come back with the values still in it —
// losing a filled-in form is how a sale gets abandoned.
{
const auto rejected = srv.Post("/shop/fp6-pmos",
"email=bad&name=Ada&street=Main%201&postal=1234AB&city=Delft&country=NLD");
for (std::string_view probe : { "value=\"bad\"", "value=\"NLD\"", "value=\"Ada\"",
"value=\"Main 1\"", "value=\"Delft\"" }) {
Check(rejected.body.find(probe) != std::string::npos,
std::format("rejected form preserves {}", probe));
}
Check(rejected.body.find("field__error") != std::string::npos,
"rejected form shows a field error");
}
// A refused destination says why, in the form, with the address still in
// it — the visitor should learn the shop does not sell there, not that
// something went wrong.
{
const auto refused = srv.Post("/shop/fp6-pmos",
"email=us%40example.org&name=Pat&street=1%20Main%20St&postal=43004&city=Columbus&country=US");
Check(refused.body.find("does not sell or ship to the United States or Canada")
!= std::string::npos,
"refusal explains itself on the form");
Check(refused.body.find("value=\"Pat\"") != std::string::npos,
"a refused submission keeps what was typed");
}
// The buy panel warns before anyone fills it in, and the preview script
// carries the same list so it cannot quote a total the server would
// refuse.
srv.BodyHas("/shop/fp6-pmos", "does not sell or ship to the United States or Canada",
"buy panel states where the shop does not sell");
srv.BodyHas("/shop/fp6-pmos", "&quot;x&quot;:[&quot;US&quot;,&quot;CA&quot;]",
"total preview knows the refused destinations");
// The honeypot message must not name the trap, or it teaches the next
// bot. Only the ERROR NOTICE is inspected: the re-rendered form
// legitimately contains the name="website" field itself — that IS the
// trap, re-armed.
{
const auto pot = srv.Post("/shop/fp6-pmos", Good("&website=x"));
std::smatch m;
Check(std::regex_search(pot.body, m, std::regex(R"lit(notice--error">([^<]*))lit")),
"honeypot rejection renders an error notice");
if (!m.empty()) {
std::string notice = m[1].str();
for (char& c : notice) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
const bool names = notice.find("honeypot") != std::string::npos
|| notice.find("website") != std::string::npos
|| notice.find("hidden") != std::string::npos
|| notice.find("trap") != std::string::npos;
Check(!names, "honeypot failure does not name the trap", m[1].str());
}
}
}
} // namespace
int main(int argc, char** argv) {
ServerOptions options;
options.gpg = true;
options.mailer = true;
TestServer srv(argv[1], 8217, options);
if (srv.ShopOpen()) {
OpenShopLifecycle(srv);
} else {
ComingSoon(srv);
}
AlwaysOnValidation(srv);
if (srv.ShopOpen()) {
RejectedFormEcho(srv);
}
return Finish();
}

View file

@ -0,0 +1,273 @@
/*
catcrafts.net
Copyright (C) 2026 Catcrafts
The source code of this website is made available for viewing purposes only.
No permission is granted to copy, modify, distribute, or create derivative works.
*/
// The financials page and the bunq mutation ingest behind it. The callback is
// the only path by which a stranger's money reaches a public number on this
// site, so its parser, its classifier and above all its default-deny
// behaviour are pinned here. A rule that accidentally claims everything, or a
// classifier that treats an unrecognised transfer as a donation, would
// publish a figure that is simply untrue.
import std;
import Catcrafts.Shared;
import Catcrafts.Server;
using namespace Catcrafts;
namespace {
int failures = 0;
void Check(bool ok, std::string_view what, std::string_view got = {}) {
if (ok) return;
++failures;
std::println(std::cerr, "FAIL: {}{}{}", what,
got.empty() ? "" : " got: ", got);
}
// ── the financials page ───────────────────────────────────────────────
void FinancialsPage() {
const Financials fin = LoadFinancials(
R"({"as_of":"2026-08-14",)"
R"("donations":{"count":3,"total_minor":4500},)"
R"("expenses":[{"label":"Hosting","total_minor":1200},)"
R"({"label":"Insurance","total_minor":3600},)"
R"({"label":"Inventory","total_minor":230000}]})");
Check(fin.Loaded(), "financials: loads");
Check(fin.donationCount == 3 && fin.donationsMinor == 4500,
"financials: donations aggregate");
Check(fin.expenses.size() == 3 && fin.expenses[0].label == "Hosting"
&& fin.expenses[1].totalMinor == 3600
&& fin.expenses[2].label == "Inventory",
"financials: expense categories in file order");
Check(fin.ExpensesMinor() == 234800, "financials: expense total");
Check(!LoadFinancials("garbage").Loaded(),
"financials: malformed input yields none");
Check(!LoadFinancials(R"({"donations":{"count":1,"total_minor":1}})").Loaded(),
"financials: undated figures stay unpublished");
Check(LoadFinancials(R"({"as_of":"2026-08-14","expenses":[{"total_minor":5}]})")
.expenses.empty(),
"financials: a category without a label is dropped");
Check(ParseRoute("/financials").kind == RouteKind::Financials,
"route: /financials");
Check(ParseRoute("/financials/").kind == RouteKind::Financials,
"route: /financials/ normalises");
bool inSitemap = false;
for (std::string_view p : SitemapPaths()) inSitemap = inSitemap || p == "/financials";
Check(inSitemap, "route: /financials is in the sitemap");
const LegalPage& notes = Content::FinancialsPage();
Check(notes.slug == "financials" && !notes.lede.empty()
&& notes.sections.size() >= 2,
"content: financials notes present");
// The PROMISE, not the wording that happens to carry it. Pinning a
// phrase in the lede made rewriting the page's opening sentence a
// test failure, which is backwards: the lede is voice, the promise
// below is the commitment that must survive every edit.
bool statesPromise = false;
for (const LegalSection& sec : notes.sections) {
for (const std::string& para : sec.body) {
if (para.find("No individual transactions") != std::string::npos) {
statesPromise = true;
}
}
}
Check(statesPromise, "content: financials page states what it never publishes");
// The rendered page: live sales plus the bank aggregates, with the
// machine-readable copy the e2e suite reads.
const Views::RenderedPage fp = Views::RenderFinancials(2, 113745, fin);
Check(fp.status == 200, "financials: renders");
Check(fp.main.View().find("data-fin-sales-minor=\"113745\"") != std::string_view::npos
&& fp.main.View().find("data-fin-expenses-minor=\"234800\"")
!= std::string_view::npos,
"financials: machine-readable totals");
Check(fp.main.View().find("€1137.45") != std::string_view::npos
&& fp.main.View().find("€1182.45") != std::string_view::npos,
"financials: income rows and their total render");
Check(fp.main.View().find("Hosting") != std::string_view::npos
&& fp.main.View().find("€2348") != std::string_view::npos,
"financials: expense categories and their total render");
// Net = income - expenses = (4500 + 113745) - 234800 = -116555.
// Deliberately a NEGATIVE case: a shop that has just bought stock is
// the normal way for this figure to go below zero, and "€-1165.55" is
// what must render rather than a mangled or unsigned number.
Check(fp.main.View().find("data-fin-net-minor=\"-116555\"") != std::string_view::npos
&& fp.main.View().find("€-1165.55") != std::string_view::npos,
"financials: net renders, and renders negative honestly");
Check(Money::FormatEuro(-26260) == "€-262.60" && Money::FormatEuro(-500) == "€-5.00",
"financials: negative euro formatting");
// Before the bank figures exist the page says so instead of lying
// with zeros — and publishes no donation figures at all.
const Views::RenderedPage bare = Views::RenderFinancials(0, 0, Financials{});
Check(bare.main.View().find("data-fin-sales-count=\"0\"") != std::string_view::npos
&& bare.main.View().find("not been published yet") != std::string_view::npos
&& bare.main.View().find("data-fin-donations-count") == std::string_view::npos,
"financials: unpublished bank figures say so and publish nothing");
// And no net either: income minus an unknown expense side is not a
// net of anything, and printing sales there would read as a company
// with no costs.
Check(bare.main.View().find("data-fin-net-minor") == std::string_view::npos
&& bare.main.View().find(">Net<") == std::string_view::npos,
"financials: no net figure while expenses are unpublished");
// Lifetime sales: ever-paid counts, awaiting doesn't, a refund after
// payment stays counted, a hand-shipped legacy order counts too.
Server::OrderRecord paid;
paid.totalMinor = 56330;
paid.paidAt = "2026-08-14T00:00:00Z";
paid.status = "paid";
Server::OrderRecord waiting;
waiting.totalMinor = 99999;
Server::OrderRecord refunded;
refunded.totalMinor = 56930;
refunded.paidAt = "2026-08-14T00:00:00Z";
refunded.status = "cancelled";
Server::OrderRecord shipped;
shipped.totalMinor = 200;
shipped.status = "shipped";
const std::array<Server::OrderRecord, 4> orders{ paid, waiting, refunded, shipped };
const Server::SalesSummary sum = Server::SummarizeSales(orders);
Check(sum.count == 3 && sum.totalMinor == 56330 + 56930 + 200,
"financials: sales count ever-paid orders only");
Check(Server::SummarizeSales({}).count == 0,
"financials: empty ledger sums to zero");
}
// ── the bunq mutation callback ────────────────────────────────────────
void BunqIngest() {
using Server::ParseSignedAmountToMinor;
Check(ParseSignedAmountToMinor("25.00") == 2500, "bunq: positive amount");
Check(ParseSignedAmountToMinor("-12.50") == -1250, "bunq: outgoing is negative");
Check(ParseSignedAmountToMinor("+5") == 500, "bunq: explicit plus");
Check(!ParseSignedAmountToMinor("1.234").has_value(), "bunq: too many decimals");
Check(!ParseSignedAmountToMinor("nonsense").has_value(), "bunq: non-numeric");
Check(!ParseSignedAmountToMinor("").has_value(), "bunq: empty amount");
// A realistic payload: the mutation is nested two wrappers deep, and
// the parser finds it by SHAPE so a wrapper rename cannot silently
// turn every callback into a no-op.
constexpr std::string_view kPayload =
R"({"NotificationUrl":{"target_url":"https://catcrafts.net/api/bunq/s",)"
R"("category":"MUTATION","event_type":"MUTATION_CREATED","object":{"Payment":{)"
R"("id":4823,"created":"2026-08-14 09:31:02.123456","monetary_account_id":9911,)"
R"("amount":{"currency":"EUR","value":"25.00"},)"
R"("description":"Thanks for imsd!",)"
R"("counterparty_alias":{"iban":"NL55BUNQ2025123456","display_name":"A Donor"}}}}})";
const auto m = Server::ParseBunqMutation(kPayload);
Check(m.has_value(), "bunq: nested payload parses");
if (m) {
Check(m->id == "4823", "bunq: numeric id travels as text");
Check(m->amountMinor == 2500 && m->currency == "EUR", "bunq: amount and currency");
Check(m->account == "9911", "bunq: monetary account");
Check(m->counterpartyIban == "NL55BUNQ2025123456", "bunq: counterparty iban");
// The time of day never survives the parser: an exact timestamp
// is the one field that would let a watcher pin a donation to a
// person who mentioned donating.
Check(m->created == "2026-08-14", "bunq: only the date is kept");
}
Check(!Server::ParseBunqMutation("garbage").has_value(), "bunq: malformed payload");
Check(!Server::ParseBunqMutation(R"({"NotificationUrl":{"category":"MUTATION"}})")
.has_value(),
"bunq: a notification with no mutation yields nothing");
const Server::FinancialRules rules = Server::LoadFinancialRules(
R"({"donation_accounts":[9911],)"
R"("rules":[)"
R"({"iban":"NL01OWNSELF0000000","group":"ignore"},)"
R"({"description_contains":"hetzner","group":"expense","label":"Hosting"},)"
R"({"iban":"DE02SUPPLIER000000","group":"expense","label":"Inventory"},)"
R"({"group":"expense","label":"Claims everything"},)"
R"({"iban":"NL03TYPO0000000000","group":"nonsense","label":"X"},)"
R"({"iban":"NL04NOLABEL0000000","group":"expense"}]})");
Check(rules.donationAccounts.size() == 1 && rules.donationAccounts[0] == "9911",
"bunq: numeric donation account loads as text");
// Three of the six survive: the criterion-less rule would claim every
// mutation, the typo'd group is not a category, and an expense with
// no label has nothing to render as.
Check(rules.rules.size() == 3, "bunq: unsafe rules are dropped at load");
// Incoming on the donation account, claimed by no explicit rule.
Check(m && Server::ClassifyMutation(*m, rules).group == "donations",
"bunq: incoming on the donation account is a donation");
Server::BankMutation x = *m;
// Money LEAVING the donation account is not a gift to this company.
x.amountMinor = -2500;
Check(Server::ClassifyMutation(x, rules).group.empty(),
"bunq: outgoing on the donation account is not a donation");
// An explicit ignore beats the donation-account default, which is how
// the owner's own transfer between accounts stays out of the total.
x = *m;
x.counterpartyIban = "nl01ownself0000000";
Check(Server::ClassifyMutation(x, rules).group == "ignore",
"bunq: an explicit rule beats the donation default, case-insensitively");
// Foreign currency is never folded into a euro total.
x = *m;
x.currency = "USD";
Check(Server::ClassifyMutation(x, rules).group.empty(),
"bunq: non-euro is never counted");
// Default-deny: an ordinary transfer from a stranger, on an account
// that is not the donation one, is withheld rather than guessed at.
x = *m;
x.account = "1234";
x.counterpartyIban = "NL99UNKNOWN0000000";
x.description = "";
Check(Server::ClassifyMutation(x, rules).group.empty(),
"bunq: an unmatched mutation is withheld, not guessed");
Server::BankMutation bill;
bill.currency = "EUR";
bill.amountMinor = -1200;
bill.description = "HETZNER ONLINE GMBH invoice";
bill.created = "2026-08-15";
const Server::MutationClass billClass = Server::ClassifyMutation(bill, rules);
Check(billClass.group == "expense" && billClass.label == "Hosting",
"bunq: description matching, case-insensitively");
// Folding into the aggregates.
Financials fin;
Server::ApplyMutation(fin, Server::ClassifyMutation(*m, rules), *m);
Check(fin.donationCount == 1 && fin.donationsMinor == 2500,
"bunq: a donation moves the count and the total");
Check(fin.asOf == "2026-08-14", "bunq: as-of follows the mutation date");
Server::ApplyMutation(fin, billClass, bill);
Check(fin.expenses.size() == 1 && fin.expenses[0].label == "Hosting"
&& fin.expenses[0].totalMinor == 1200,
"bunq: an outgoing bill becomes a positive expense");
Check(fin.asOf == "2026-08-15", "bunq: as-of advances");
// A supplier refund reduces the category rather than appearing as
// income, and never drags the as-of date backwards.
Server::BankMutation refund = bill;
refund.amountMinor = 500;
refund.created = "2026-08-01";
Server::ApplyMutation(fin, billClass, refund);
Check(fin.expenses[0].totalMinor == 700, "bunq: a refund reduces its category");
Check(fin.asOf == "2026-08-15", "bunq: as-of never moves backwards");
// An unclassified mutation touches nothing at all.
const Financials before = fin;
Server::ApplyMutation(fin, Server::MutationClass{}, *m);
Check(fin.donationCount == before.donationCount
&& fin.ExpensesMinor() == before.ExpensesMinor(),
"bunq: an unclassified mutation changes no total");
}
} // namespace
int main() {
FinancialsPage();
BunqIngest();
if (failures != 0) {
std::println(std::cerr, "{} check(s) failed", failures);
return 1;
}
return 0;
}

View file

@ -0,0 +1,182 @@
/*
catcrafts.net
Copyright (C) 2026 Catcrafts
The source code of this website is made available for viewing purposes only.
No permission is granted to copy, modify, distribute, or create derivative works.
*/
// The Markdown renderer, which is the newest place untrusted text becomes
// markup — post bodies are fetched from someone else's server, so every one of
// these assertions is ultimately about the same thing: nothing in a body can
// escape into the document. The structural cases are here too, because a parser
// that silently drops a construct loses content invisibly.
import std;
import Catcrafts.Shared;
using namespace Catcrafts;
namespace {
int failures = 0;
void Check(bool ok, std::string_view what, std::string_view got = {}) {
if (ok) return;
++failures;
std::println(std::cerr, "FAIL: {}{}{}", what,
got.empty() ? "" : " got: ", got);
}
void CheckEq(const Html::SafeHtml& actual, std::string_view expected, std::string_view what) {
Check(actual.View() == expected, what, actual.View());
}
} // namespace
int main() {
auto md = [](std::string_view text,
std::span<const PostMedia> media = {}) {
return Markdown::Render(text, media);
};
// ── the guarantee ─────────────────────────────────────────────────
CheckEq(md("<script>alert(1)</script>"),
"<p>&lt;script&gt;alert(1)&lt;/script&gt;</p>", "md: html is text, never markup");
CheckEq(md("![x](javascript:alert(1))"),
R"(<div class="post-media"><img class="post-media__item" loading="lazy" )"
R"(decoding="async" alt="x" src="#"></div>)",
"md: javascript: image source neutralised");
CheckEq(md("[x](javascript:alert(1))"),
R"(<p><a href="#" rel="noopener">x</a></p>)",
"md: javascript: link neutralised");
CheckEq(md("a \" b & c"), "<p>a &quot; b &amp; c</p>", "md: quotes and ampersands escaped");
// A code span is verbatim text, and verbatim is exactly where an escaper
// is most often forgotten.
CheckEq(md("`<b>`"), "<p><code>&lt;b&gt;</code></p>", "md: code span escaped");
// ── blocks ────────────────────────────────────────────────────────
CheckEq(md(""), "", "md: empty body renders nothing");
CheckEq(md("plain text"), "<p>plain text</p>", "md: paragraph");
// Demotion by one: the page h1 is the post title, so a body's own top-level
// heading is a section within it.
CheckEq(md("# Heading"), "<h2>Heading</h2>", "md: h1 demoted to h2");
CheckEq(md("### Heading"), "<h4>Heading</h4>", "md: h3 demoted to h4");
CheckEq(md("#nothashtag"), "<p>#nothashtag</p>", "md: # without a space is not a heading");
CheckEq(md("> quoted"),
R"(<blockquote class="post-body__quote"><p>quoted</p></blockquote>)",
"md: blockquote");
// The quoted lines are re-parsed, so a multi-paragraph quote keeps its
// paragraphs instead of collapsing into one run-on line.
CheckEq(md("> one\n>\n> two"),
R"(<blockquote class="post-body__quote"><p>one</p><p>two</p></blockquote>)",
"md: blockquote keeps its paragraphs");
CheckEq(md("- a\n- b"),
R"(<ul class="post-body__list"><li>a</li><li>b</li></ul>)", "md: unordered list");
CheckEq(md("1. a\n2. b"),
R"(<ol class="post-body__list"><li>a</li><li>b</li></ol>)", "md: ordered list");
// A list resumed after an interrupting paragraph continues its numbering.
// Without the start attribute the mini-guide in one of these posts renders
// as steps 1-4 followed by steps 1, 2, 3.
CheckEq(md("5. e"),
R"(<ol class="post-body__list" start="5"><li>e</li></ol>)",
"md: ordered list keeps the number it announced");
// Blank lines between items are spacing, not seven one-item lists.
CheckEq(md("1. a\n\n2. b"),
R"(<ol class="post-body__list"><li>a</li><li>b</li></ol>)",
"md: blank line inside a list does not split it");
CheckEq(md("---"), "<hr>", "md: thematic break");
CheckEq(md("- - -"), "<hr>", "md: spaced rule is not a one-item list");
// Whitespace in pasted terminal output is the content.
CheckEq(md("```\n a\tb\n```"),
"<pre class=\"post-body__code\"><code> a\tb\n</code></pre>",
"md: fenced code is verbatim");
// An unterminated fence must not swallow the document into nothing.
Check(md("```\nx").View().find("<code>x") != std::string_view::npos,
"md: unterminated fence still renders its content");
// ── inline ────────────────────────────────────────────────────────
CheckEq(md("**bold**"), "<p><strong>bold</strong></p>", "md: strong");
CheckEq(md("*em*"), "<p><em>em</em></p>", "md: emphasis");
CheckEq(md("2 * 3 * 4"), "<p>2 * 3 * 4</p>", "md: spaced asterisks stay literal");
// Underscores are deliberately inert: these posts paste kernel symbol
// names into prose, and italicising half of one is worse than not
// italicising a word that used the underscore form.
CheckEq(md("kworker/u16:8-qc_ufs_qos_swq"),
"<p>kworker/u16:8-qc_ufs_qos_swq</p>", "md: underscores are not emphasis");
CheckEq(md("\\*literal\\*"), "<p>*literal*</p>", "md: backslash escape");
CheckEq(md("[label](https://x.example/y)"),
R"(<p><a href="https://x.example/y" rel="noopener">label</a></p>)", "md: link");
// Bare addresses are pasted constantly in these posts; leaving them inert
// would strip most of the outbound value out of the page.
CheckEq(md("see https://x.example/y"),
R"(<p>see <a href="https://x.example/y">https://x.example/y</a></p>)",
"md: bare URL autolinked");
// ── embedded media ────────────────────────────────────────────────
// A paragraph that is nothing but images becomes the same media block the
// cards use, rather than a <p> of pictures.
CheckEq(md("![a](/media/x.webp)"),
R"(<div class="post-media"><img class="post-media__item" loading="lazy" )"
R"(decoding="async" alt="a" src="/media/x.webp"></div>)",
"md: image-only paragraph is a media block");
Check(md("text ![a](/media/x.webp)").View().starts_with("<p>text <img"),
"md: an image inside a sentence stays inline");
// Dimensions come from the sidecar list, because Markdown syntax has
// nowhere to carry them — and without them the prose below every
// screenshot jumps as the file arrives.
{
std::vector<PostMedia> media;
PostMedia img;
img.src = "/media/x.webp";
img.kind = "image";
img.avif = "/media/x.avif";
img.fallback = "/media/x.png";
img.width = 800;
img.height = 600;
media.push_back(img);
PostMedia vid;
vid.src = "/media/v.mp4";
vid.kind = "video";
vid.poster = "/media/v.poster.webp";
vid.fallback = "/media/v.h264.mp4";
vid.width = 1080;
vid.height = 1920;
media.push_back(vid);
const auto out = md("![a](/media/x.webp)", media);
Check(out.View().find(R"(width="800" height="600")") != std::string_view::npos,
"md: inline image carries its dimensions", out.View());
// Routed through :Media, so a body image gets the same format ladder a
// card image does rather than a second, plainer implementation.
Check(out.View().find(R"(<source srcset="/media/x.avif" type="image/avif">)")
!= std::string_view::npos
&& out.View().find(R"(src="/media/x.png")") != std::string_view::npos,
"md: inline image gets the avif/png ladder", out.View());
// An inline video gets the same treatment a headline one does,
// fallback source and all.
const auto vout = md("![](/media/v.mp4)", media);
Check(vout.View().find(R"(poster="/media/v.poster.webp")") != std::string_view::npos
&& vout.View().find("codecs=av01") != std::string_view::npos
&& vout.View().find(R"(<source src="/media/v.h264.mp4")") != std::string_view::npos,
"md: inline video gets poster and H.264 fallback", vout.View());
}
// ── termination ───────────────────────────────────────────────────
// Unbalanced delimiters are the classic way to hang a hand-written
// parser, and a body is input from someone else's server.
Check(!md("**unclosed").View().empty(), "md: unclosed strong terminates");
Check(!md("[unclosed](").View().empty(), "md: unclosed link terminates");
Check(!md("![](").View().empty(), "md: unclosed image terminates");
Check(!md("`unclosed").View().empty(), "md: unclosed code span terminates");
Check(!md("> > > > > > > > deep").View().empty(), "md: over-deep nesting terminates");
if (failures != 0) {
std::println(std::cerr, "{} check(s) failed", failures);
return 1;
}
return 0;
}

View file

@ -0,0 +1,76 @@
/*
catcrafts.net
Copyright (C) 2026 Catcrafts
The source code of this website is made available for viewing purposes only.
No permission is granted to copy, modify, distribute, or create derivative works.
*/
// The shop front: prices rendered from the same integers the checkout
// charges, the currency conversions the shop card carries, and the copy
// around them. Runs in both shop states — coming-soon asserts the closed
// shape, launch re-arms the open one with no edit here.
import std;
import Catcrafts.E2eHarness;
using namespace Catcrafts::E2e;
int main(int argc, char** argv) {
TestServer srv(argv[1], 8216);
// The price is rendered from the same integers the checkout charges, with
// the derived ex-VAT twin alongside — asserting both pins the arithmetic.
srv.BodyHas("/shop/fp6-pmos", "€563.30",
"product page shows the from-price (green supplier + €50)");
srv.BodyHas("/shop/fp6-pmos", "€465.54", "product page shows the derived ex-VAT price");
srv.BodyHas("/shop/fp6-pmos", ">from<", "product page marks the price as a from-price");
srv.BodyHas("/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.
srv.BodyHas("/shop/fp6-pmos", "Black &mdash; €569.30", "colour selector prices black");
srv.BodyHas("/shop/fp6-pmos", "White &mdash; €654.88", "colour selector prices white");
if (srv.ShopOpen()) {
srv.BodyHas("/shop/fp6-pmos", "data-cc=", "form embeds the pricing blob");
srv.BodyHas("/shop/fp6-pmos", "id=\"cc-total\"", "live total element present");
} else {
srv.BodyHas("/shop/fp6-pmos", "Coming soon", "coming-soon notice on the buy panel");
srv.BodyHas("/shop", "coming soon", "shop card carries the coming-soon badge");
srv.BodyLacks("/shop/fp6-pmos", "<form", "no order form while coming soon");
}
srv.BodyHas("/shop/fp6-pmos", "src=\"/fp6-pmos.jpg\"", "product page embeds the photo");
srv.BodyHas("/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.
Check(std::filesystem::exists("images/fp6-pmos.jpg"), "product photo exists in the repo");
srv.BodyHas("/shop/fp6-pmos", "not yet verified", "emergency-calling caveat is on the page");
srv.BodyLacks("/shop", "reservation", "no reservation copy survives on /shop");
srv.BodyLacks("/shop/fp6-pmos", "Reserve one", "no reservation form survives");
// The shop card: one euro number as the crawler/no-JS text, every
// supported currency pre-formatted server-side as a data attribute for
// the script to pick from. Converted amounts carry "~". GBP converts the
// ex-VAT price; SEK (an EU member's currency) converts the VAT-inclusive
// price. USD and CAD are absent on purpose — the shop refuses those
// destinations, so it does not quote a local price to them either.
srv.BodyHas("/shop", "class=\"price__single\"", "shop card renders the single-number price");
srv.BodyHas("/shop", "data-gbp=\"", "shop card carries a GBP conversion");
srv.BodyHas("/shop", "data-sek=\"~kr ", "shop card carries an SEK conversion");
srv.BodyHas("/shop", "data-world=\"€465.54\"", "shop card carries the euro export fallback");
srv.BodyLacks("/shop", "data-usd=", "no USD price for a country the shop refuses");
srv.BodyLacks("/shop", "data-cad=", "no CAD price for a country the shop refuses");
// The product page gets the same headline element, so a British visitor
// sees ~£ at the top there too, and the buy card states the customs
// position plainly.
srv.BodyHas("/shop/fp6-pmos", "data-gbp=\"", "product page headline carries the conversion");
srv.BodyHas("/shop/fp6-pmos", "indicative only",
"buy card says converted prices are indicative");
srv.BodyHas("/shop/fp6-pmos", "customs authority",
"buy card names whose problem import charges are");
srv.BodyLacks("/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.
srv.BodyLacks("/shop/fp6-pmos", "EU VAT", "price label does not call 21% an EU-wide rate");
return Finish();
}

View file

@ -0,0 +1,183 @@
/*
catcrafts.net
Copyright (C) 2026 Catcrafts
The source code of this website is made available for viewing purposes only.
No permission is granted to copy, modify, distribute, or create derivative works.
*/
// The financials page over real HTTP, and the bunq mutation callback that
// feeds it. Liveness is the page's promise: the aggregates file appears and
// the very next request reflects it — no restart, no cache, no delay. The
// callback is the only path by which a stranger's money reaches a public
// number, so idempotency, default-deny, and the no-leak guarantees are pinned
// against the real endpoint here.
import std;
import Catcrafts.E2eHarness;
using namespace Catcrafts::E2e;
namespace {
// fin_attr <attribute> -> its value on the live page
std::string FinAttr(TestServer& srv, std::string_view attr) {
const std::string body = srv.Body("/financials");
std::smatch m;
if (std::regex_search(body, m, std::regex(std::format(R"lit({}="([0-9]*)")lit", attr)))) {
return m[1].str();
}
return {};
}
std::string BunqPayload(std::string_view id, std::string_view account,
std::string_view value, std::string_view iban,
std::string_view description) {
std::string p = R"({"NotificationUrl":{"category":"MUTATION","event_type":"MUTATION_CREATED","object":{"Payment":{"id":)";
p += id;
p += R"(,"created":"2026-08-15 09:31:02.000000","monetary_account_id":)";
p += account;
p += R"(,"amount":{"currency":"EUR","value":")";
p += value;
p += R"("},"description":")";
p += description;
p += R"(","counterparty_alias":{"iban":")";
p += iban;
p += R"(","display_name":"Someone"}}}}})";
return p;
}
} // namespace
int main(int argc, char** argv) {
// The secret IS the last segment of the callback URL, and setting it is
// what brings the endpoint into existence — unset, the path is an
// ordinary 404. Note what is NOT here: a bunq API key. One could initiate
// payments, so no such key ever reaches the server; it only receives.
constexpr std::string_view kSecret = "e2e-callback-secret-not-a-real-one";
ServerOptions options;
options.env.emplace_back("BUNQ_CALLBACK_SECRET", std::string(kSecret));
TestServer srv(argv[1], 8215, options);
// ── the financials page ───────────────────────────────────────────
// Aggregate-only by construction: totals and counts, machine-readable via
// the data-fin-* attributes. Live is the page's promise, so it must never
// sit in a shared cache.
srv.HeaderHas("/financials", "cache-control", "no-store", "financials are never cached");
srv.BodyHas("/financials", "data-fin-sales-count=\"0\"", "financials start at zero sales");
// Before the bank-aggregates file exists the page says so, and publishes
// no donation figures at all — an unknowable €0 would be a lie.
srv.BodyHas("/financials", "not been published yet", "unpublished bank figures say so");
srv.BodyLacks("/financials", "data-fin-donations-count",
"no donation figures before the file exists");
// The aggregates file appears, exactly as the owner's tooling will write
// it, and the very next request reflects it — this is the liveness the
// donation counter depends on.
WriteFile(std::filesystem::path(srv.Orders().string() + ".financials.json"),
R"({"as_of":"2026-08-14",)"
"\n"
R"( "donations":{"count":3,"total_minor":4500},)"
"\n"
R"( "expenses":[{"label":"Hosting","total_minor":1200},)"
"\n"
R"( {"label":"Inventory","total_minor":230000}]})"
"\n");
srv.BodyHas("/financials", "data-fin-donations-count=\"3\"", "donation count picked up live");
srv.BodyHas("/financials", "data-fin-expenses-minor=\"231200\"",
"expense total picked up live");
// Net = (donations 4500 + sales 0) - expenses 231200. Negative on
// purpose: a shop that has bought stock but not sold it is exactly this
// shape, and the figure has to survive going below zero.
srv.BodyHas("/financials", "data-fin-net-minor=\"-226700\"",
"net is published and may be negative");
srv.BodyHas("/financials", "€-2267", "a negative net renders with its sign");
srv.BodyHas("/financials", "Hosting", "an expense category renders");
srv.BodyHas("/financials", "Inventory", "a second expense category renders");
srv.BodyHas("/financials", "2026-08-14", "bank figures carry their as-of date");
// ── the bunq mutation callback ────────────────────────────────────
// The rules are written here rather than at startup on purpose — they are
// re-read per callback, so a new rule takes effect without a restart.
WriteFile(std::filesystem::path(srv.Orders().string() + ".financial-rules.json"),
R"({"donation_accounts":[9911],)"
"\n"
R"( "rules":[{"description_contains":"hetzner","group":"expense","label":"Hosting"},)"
"\n"
R"( {"iban":"NL01OWNSELF0000000","group":"ignore"}]})"
"\n");
const std::string cb = std::format("/api/bunq/{}", kSecret);
auto bunqPost = [&](std::string_view id, std::string_view account,
std::string_view value, std::string_view iban,
std::string_view description) {
return srv.Post(cb, BunqPayload(id, account, value, iban, description),
"application/json").status;
};
// An endpoint guarded by a secret must not confirm its own existence:
// every unauthorised shape is the same 404 an unknown order token gets.
{
const auto wrong = srv.Post("/api/bunq/wrong-secret", "{}", "application/json");
Check(wrong.status == "404", "POST /api/bunq/wrong-secret -> 404", wrong.status);
}
srv.CheckStatus(cb, "404"); // GET on the right URL is still not a callback
srv.CheckStatus(cb, "404", "HEAD");
// A donation arrives on the donation account. No rule names the sender —
// donors are strangers, which is exactly why the account is what
// classifies.
Check(bunqPost("4823", "9911", "25.00", "NL55BUNQ2025123456", "Thanks for imsd") == "200",
"the callback accepts a mutation");
Check(FinAttr(srv, "data-fin-donations-count") == "4"
&& FinAttr(srv, "data-fin-donations-minor") == "7000",
"a donation ticks the public counter immediately");
// bunq redelivers a callback it did not see a 2xx for, and can redeliver
// one it did. Counting that twice would publish money that never arrived.
bunqPost("4823", "9911", "25.00", "NL55BUNQ2025123456", "Thanks for imsd");
Check(FinAttr(srv, "data-fin-donations-count") == "4"
&& FinAttr(srv, "data-fin-donations-minor") == "7000",
"a redelivered mutation is not counted twice");
// Default-deny: money no rule claims is WITHHELD from the page. It is
// logged for classification, never published as a guess.
Check(bunqPost("4824", "1234", "90.00", "NL99UNKNOWN00000000", "unlabelled transfer") == "200",
"an unclassifiable mutation is still accepted (no redelivery loop)");
Check(FinAttr(srv, "data-fin-donations-count") == "4"
&& FinAttr(srv, "data-fin-expenses-minor") == "231200",
"an unclassified mutation is withheld from every total");
// An outgoing bill matched by description becomes a positive expense.
bunqPost("4825", "9911", "-12.00", "DE00HETZNER00000000", "HETZNER ONLINE GMBH");
Check(FinAttr(srv, "data-fin-expenses-minor") == "232400",
"an outgoing bill lands in its expense category");
srv.BodyHas("/financials", "2026-08-15", "the as-of date advances with the mutations");
// The page still publishes nothing but aggregates: no counterparty, no
// description, no id, no timestamp. This is the assertion that would
// catch a well-meant future edit adding a "recent activity" list.
for (std::string_view leak : { "NL55BUNQ2025123456", "Someone", "Thanks for imsd",
"4823", "09:31" }) {
srv.BodyLacks("/financials", std::string(leak),
std::format("financials leak no transaction detail ({})", leak));
}
// And nothing identifying was written to disk either — the ingest ledger
// holds opaque ids and counters, and no other file learned the donor
// exists.
{
bool persisted = false;
for (const auto& entry :
std::filesystem::recursive_directory_iterator(srv.Work())) {
if (!entry.is_regular_file()) continue;
if (ReadFile(entry.path()).find("NL55BUNQ2025123456") != std::string::npos) {
persisted = true;
std::println(std::cerr, " IBAN found in {}", entry.path().string());
}
}
Check(!persisted, "no counterparty IBAN is persisted anywhere");
}
return Finish();
}

View file

@ -0,0 +1,245 @@
/*
catcrafts.net
Copyright (C) 2026 Catcrafts
The source code of this website is made available for viewing purposes only.
No permission is granted to copy, modify, distribute, or create derivative works.
*/
// Post media and post pages over real HTTP. 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.
import std;
import Catcrafts.E2eHarness;
using namespace Catcrafts::E2e;
namespace {
// Media loaded from a third party — `poster` is in the list because a video
// poster is fetched on page load exactly like an <img> src is.
const std::regex kThirdPartyMedia(
R"((src|srcset|href|poster)="https?://[^"]*\.(mp4|webm|webp|avif|png|jpe?g|gif))");
std::vector<std::string> PostPathsFromSitemap(TestServer& srv, std::size_t limit) {
const std::string sitemap = srv.Body("/sitemap.xml");
const std::regex slug(R"(/posts/[a-z0-9-]+)");
std::vector<std::string> out;
for (auto it = std::sregex_iterator(sitemap.begin(), sitemap.end(), slug);
it != std::sregex_iterator() && out.size() < limit; ++it) {
if (std::ranges::find(out, it->str()) == out.end()) out.push_back(it->str());
}
return out;
}
} // namespace
int main(int argc, char** argv) {
TestServer srv(argv[1], 8214);
const std::string posts = srv.Body("/posts");
// ── post media ────────────────────────────────────────────────────
Check(std::regex_search(posts, std::regex(R"(<(img|video) class="post-media__item")")),
"/posts embeds its media");
Check(!std::regex_search(posts, kThirdPartyMedia),
"/posts loads no media from a third party");
// Dimensions prevent layout shift as each file arrives. Needs ffprobe at
// fetch time — a build host without it produces no dimensions at all,
// which is what this catches.
Check(std::regex_search(posts, std::regex(
R"(<img class="post-media__item"[^>]*width="[0-9]+" height="[0-9]+")")),
"images carry width/height");
// 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.
Check(std::regex_search(posts, std::regex(
R"(<video class="post-media__item"[^>]*width="[0-9]+" height="[0-9]+")")),
"videos carry width/height");
// A poster is the frame shown before anyone presses play, and these posts
// ARE their video. "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 broke.
Check(std::regex_search(posts, std::regex(
R"(<video class="post-media__item"[^>]*poster="/media/)")),
"videos carry a locally-hosted poster");
// A video offering an AV1 <source> must offer an H.264 one after it: the
// codecs parameter is what lets a browser without AV1 skip to a file it
// can play. Conditional — a build whose posts carry no AV1 has nothing to
// check.
if (posts.find("codecs=av01") != std::string::npos) {
Check(std::regex_search(posts, std::regex(
R"(<source src="/media/[^"]*\.h264\.mp4" type="video/mp4">)")),
"AV1 videos carry an H.264 fallback source");
}
// preload="metadata", not auto: several 5 MB recordings must not all
// download on page load.
Check(posts.find("preload=\"metadata\"") != std::string::npos,
"video does not preload its whole body");
// ── post pages ────────────────────────────────────────────────────
// The post page is where the body lives, and the body is the reason the
// site has anything for a search engine to index beyond a list of links
// off it. Its slug is data, so take one from the page rather than
// hardcoding a title that will be wrong the week after it is written.
std::string postPath;
{
std::smatch m;
if (std::regex_search(posts, m, std::regex(R"lit(href="(/posts/[a-z0-9-]+)")lit"))) {
postPath = m[1].str();
}
}
Check(!postPath.empty(), "/posts links a post page; a body exists somewhere");
if (!postPath.empty()) {
srv.CheckStatus(postPath, "200");
srv.BodyHas("/posts", "Read the full post", "/posts offers the full post");
// And it trails the excerpt, immediately after the ellipsis the
// truncation left, rather than sitting as its own row below the
// media. The excerpt is escaped text, so nothing but the link can put
// a '<' between the two.
Check(std::regex_search(posts, std::regex(
R"(<p class="post-card__excerpt">[^<]*<a class="link-more" href="/posts/)")),
"read-more trails the excerpt");
const std::string page = srv.Body(postPath);
Check(page.find("<div class=\"post-body\">") != std::string::npos,
"post page carries the rendered body");
// Rendered, not dumped: a body that reached the page as literal
// Markdown would show its own asterisks and hashes to the reader and
// to a crawler.
Check(std::regex_search(page, std::regex(R"(<(p|h2|h3|h4|ul|ol|blockquote|pre)>)")),
"post body is real markup, not literal Markdown");
// The canonical points here, not at the instance. That is the entire
// SEO argument for hosting the body: two copies of the text exist,
// and this says which one is the original as far as this site is
// concerned.
Check(page.find("rel=\"canonical\" href=\"https://catcrafts.net/posts/")
!= std::string::npos,
"post page is its own canonical");
Check(page.find("\"@type\":\"BlogPosting\"") != std::string::npos,
"post page carries BlogPosting JSON-LD");
Check(page.find("\"@id\":\"https://catcrafts.net/#organization\"") != std::string::npos,
"post JSON-LD joins the organization node");
Check(page.find("\"@id\":\"https://catcrafts.net/about#person\"") != std::string::npos,
"post JSON-LD joins the founder node");
Check(page.find("property=\"og:type\" content=\"article\"") != std::string::npos,
"post page is an article to og:");
// Hosting the body does not mirror the discussion; the thread is
// still one click away and is still where the comments are.
Check(std::regex_search(page, std::regex(R"lit(href="https://[a-z0-9.-]+/post/[0-9]+")lit")),
"post page still links its thread");
// The body is prose, not an application.
Check(page.find("<script>") == std::string::npos,
"post page ships no executable script");
Check(page.find("<base") == std::string::npos, "post page has no base tag");
// Every inline image and video a body embeds is mirrored, exactly
// like a card's media — "everything comes from catcrafts.net" covers
// href as well as src, so a body linking a .webp on someone else's
// instance is the same leak as embedding one.
const std::vector<std::string> pages = PostPathsFromSitemap(srv, 20);
std::vector<std::string> referencedMedia;
for (const std::string& pg : pages) {
const std::string body = srv.Body(pg);
Check(!std::regex_search(body, kThirdPartyMedia),
std::format("{} loads no media from a third party", pg));
const std::regex mediaRef(R"lit((src|srcset)="(/media/[^"]+)")lit");
for (auto it = std::sregex_iterator(body.begin(), body.end(), mediaRef);
it != std::sregex_iterator(); ++it) {
const std::string f = (*it)[2].str();
if (std::ranges::find(referencedMedia, f) == referencedMedia.end()) {
referencedMedia.push_back(f);
}
}
}
// The image format ladder: AVIF first, the mirrored original next,
// and a PNG on the <img> underneath, so exactly one file is fetched
// and every browser can read one of them. Order is the whole point —
// a browser takes the first source it understands.
if (page.find("<picture>") != std::string::npos) {
Check(std::regex_search(page, std::regex(
R"(<picture><source srcset="/media/[^"]+\.avif" type="image/avif">)")),
"inline images lead with an AVIF source");
Check(std::regex_search(page, std::regex(
R"lit(<img class="post-media__item"[^>]*src="/media/[^"]+\.png")lit")),
"inline images fall back to a PNG the img itself points at");
// Every tier has to be a file that exists, or the ladder serves a
// 404 to whichever browsers pick that rung — precisely the set of
// browsers nobody testing this site is using. The files are
// served by Caddy rather than by this server, so they are checked
// on disk. NOT hardcoded to ./media: CI points the mirror at the
// persistent mount instead (E2E_MEDIA_DIR) so the copies survive
// a deploy.
std::filesystem::path mediaDir;
if (const char* env = std::getenv("E2E_MEDIA_DIR"); env && *env) {
mediaDir = env;
} else {
for (std::string_view d : { "media", "/deploy-app/media" }) {
if (std::filesystem::is_directory(d)) { mediaDir = d; break; }
}
}
if (mediaDir.empty()) {
std::println("note: no media directory found; set E2E_MEDIA_DIR — "
"the media-files-exist check did not run");
} else {
std::size_t missing = 0;
for (const std::string& f : referencedMedia) {
if (!std::filesystem::exists(mediaDir / f.substr(std::string_view("/media/").size()))) {
++missing;
// Bounded: a wrong directory makes EVERY file missing,
// and a hundred identical lines buries the one fact
// that matters.
if (missing <= 5) std::println(std::cerr, " missing: {}", f);
}
}
Check(missing == 0,
std::format("every referenced media file is in {}", mediaDir.string()),
std::format("{} referenced file(s) missing", missing));
}
} else {
std::println("note: no <picture> on {} — ffmpeg absent at mirror time? "
"the format-ladder checks did not run", postPath);
}
// Inline screenshots get dimensions from the sidecar list
// fetch-media.sh writes, because Markdown syntax has nowhere to carry
// them. Conditional: a post whose body embeds nothing has nothing to
// check.
if (page.find("<img class=\"post-media__item\"") != std::string::npos) {
Check(std::regex_search(page, std::regex(
R"(<img class="post-media__item"[^>]*width="[0-9]+" height="[0-9]+")")),
"inline body images carry width/height");
}
}
// The sitemap has to advertise the pages, or hosting the bodies buys
// nothing.
Check(std::regex_search(srv.Body("/sitemap.xml"), std::regex(
R"(<loc>https://catcrafts\.net/posts/[a-z0-9-]+</loc>)")),
"sitemap lists the post pages");
// Every outbound thread link is a real permalink: absolute https, on some
// instance, pointing at a numeric post id. A resolution failure
// legitimately falls back to the author's copy, so this checks the shape
// rather than naming a host.
{
const std::regex permalink(R"lit(href="https://[a-z0-9.-]+/post/[0-9]+")lit");
std::size_t links = 0;
for (auto it = std::sregex_iterator(posts.begin(), posts.end(), permalink);
it != std::sregex_iterator(); ++it) {
++links;
}
Check(links > 0, "posts list links its threads by permalink");
// 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.
Check(!std::regex_search(posts, std::regex(R"lit(href="/post/[0-9]+")lit")),
"no thread link resolves to catcrafts.net");
}
return Finish();
}

View file

@ -0,0 +1,173 @@
/*
catcrafts.net
Copyright (C) 2026 Catcrafts
The source code of this website is made available for viewing purposes only.
No permission is granted to copy, modify, distribute, or create derivative works.
*/
// Post pages: the routing, the loader's guard on what becomes a URL, and the
// schema.org joins that keep every post attributed to the one Organization and
// the one Person the rest of the site describes.
import std;
import Catcrafts.Shared;
using namespace Catcrafts;
namespace {
int failures = 0;
void Check(bool ok, std::string_view what, std::string_view got = {}) {
if (ok) return;
++failures;
std::println(std::cerr, "FAIL: {}{}{}", what,
got.empty() ? "" : " got: ", got);
}
} // namespace
int main() {
// ── routing ───────────────────────────────────────────────────────
Check(ParseRoute("/posts").kind == RouteKind::Posts, "route: /posts is the list");
Check(ParseRoute("/posts/hello-world").kind == RouteKind::Post, "route: /posts/<slug>");
Check(ParseRoute("/posts/hello-world").slug == "hello-world", "route: post slug captured");
Check(ParseRoute("/posts/hello-world/").kind == RouteKind::Post,
"route: trailing slash normalised");
Check(ParseRoute("/posts/Hello").kind == RouteKind::NotFound,
"route: uppercase slug is not a post URL");
Check(ParseRoute("/posts/../etc").kind == RouteKind::NotFound,
"route: traversal never reaches a lookup");
Check(NavKindFor(RouteKind::Post) == RouteKind::Posts,
"route: a post page highlights the Posts nav entry");
Check(NavKindFor(RouteKind::LegacyBlog) == RouteKind::Posts,
"route: the retired /blog URL highlights it too");
Check(NavKindFor(RouteKind::Shop) == RouteKind::Shop, "route: a nav route is its own entry");
// ── the loader ────────────────────────────────────────────────────
{
const auto posts = LoadPosts(R"([
{"title":"Good","slug":"good-post","permalink":"https://i.example/post/1",
"body":"Hello.","published":"2026-01-01T00:00:00Z",
"body_media":[{"src":"/media/a.webp","kind":"image","w":10,"h":20}]},
{"title":"Bad slug","slug":"NOT A SLUG","permalink":"https://i.example/post/2",
"body":"Hello."},
{"title":"No body","slug":"no-body","permalink":"https://i.example/post/3"}
])");
Check(posts.size() == 3, "posts: all three load");
if (posts.size() == 3) {
Check(posts[0].HasPage() && posts[0].slug == "good-post", "posts: valid slug kept");
Check(posts[0].body == "Hello.", "posts: body loaded");
Check(posts[0].bodyMedia.size() == 1 && posts[0].bodyMedia[0].width == 10,
"posts: body media loaded with dimensions");
// A slug that could never match a route would render a "read more"
// link to a 404 this site points at itself.
Check(posts[1].slug.empty() && !posts[1].HasPage(),
"posts: malformed slug is dropped, costing the page");
// A title and a link out is not a page worth minting a URL for.
Check(!posts[2].HasPage(), "posts: no body means no page");
}
Views::SiteContent content;
content.posts = posts;
Check(content.FindPost("good-post") != nullptr, "posts: found by slug");
Check(content.FindPost("no-body") == nullptr, "posts: a pageless post is not findable");
Check(content.FindPost("nope") == nullptr, "posts: unknown slug is not found");
// Which means the route 404s rather than rendering an empty article.
Check(Views::RenderRoute(ParseRoute("/posts/no-body"), content).status == 404,
"posts: a pageless slug is a real 404");
Check(Views::RenderRoute(ParseRoute("/posts/good-post"), content).status == 200,
"posts: a real post renders");
}
// ── the page ──────────────────────────────────────────────────────
{
Post p;
p.title = "Working GPS!";
p.slug = "working-gps";
p.permalink = "https://lemmy.example/post/42";
p.community = "linuxphones@lemmy.example";
p.published = "2026-06-26T23:16:25Z";
p.excerpt = "A short summary.";
p.body = "# How\n\nIt works.";
PostMedia m;
m.src = "/media/shot.webp";
m.kind = "image";
p.media.push_back(m);
const auto page = Views::RenderPost(p);
Check(page.meta.canonical == "/posts/working-gps",
"post page: canonical is this site, not the instance");
Check(page.meta.ogType == "article", "post page: og:type is article");
Check(page.meta.ogImage == "/media/shot.webp", "post page: og:image from the post media");
Check(page.meta.description == p.excerpt, "post page: description is the excerpt");
Check(page.main.View().find("<h2>How</h2>") != std::string_view::npos,
"post page: the body is rendered, not escaped away");
// The whole reason the body is hosted: the thread is still one click
// away, and the reader is told where the discussion is.
Check(page.main.View().find(p.permalink) != std::string_view::npos,
"post page: still links the thread");
// The identity graph, same joins every other page makes. A typo'd @id
// still renders and still validates — it just quietly splits this post
// away from the entity the rest of the site describes.
auto ld = Json::Parse(page.meta.jsonLd);
Check(ld && ld->IsObject() && ld->Str("@type") == "BlogPosting",
"post schema: parses as a BlogPosting");
if (ld && ld->IsObject()) {
Check(ld->Str("url") == "https://catcrafts.net/posts/working-gps",
"post schema: url is the on-site page");
Check(ld->Str("discussionUrl") == p.permalink,
"post schema: the thread is the discussion, not the content");
Check(ld->Str("datePublished") == p.published, "post schema: publication date");
const Json::Value* author = ld->Find("author");
const Json::Value* publisher = ld->Find("publisher");
Check(author && author->Str("@id") == "https://catcrafts.net/about#person",
"post schema: authored by the Person node on /about");
Check(publisher && publisher->Str("@id") == "https://catcrafts.net/#organization",
"post schema: published by the Organization node");
}
// A post with a page is advertised as one from the list and from home;
// one without keeps pointing at the thread, because there is nothing
// here to send the reader to.
const std::array<Post, 1> one{ p };
const auto list = Views::RenderPosts(one);
Check(list.main.View().find(R"(href="/posts/working-gps")") != std::string_view::npos,
"posts list: links the on-site page");
Check(list.main.View().find("Read the full post") != std::string_view::npos,
"posts list: offers the full post");
// Inside the excerpt paragraph, trailing the text — not a row of its
// own below the media, where it was the same offer made a screen
// further down.
Check(list.main.View().find(
R"(A short summary. <a class="link-more" href="/posts/working-gps">)"
R"(Read the full post</a></p>)") != std::string_view::npos,
"posts list: read-more trails the excerpt", list.main.View());
// With no excerpt there is no sentence to continue, so it falls back to
// a row rather than vanishing with the paragraph that would have held it.
Post unexcerpted = p;
unexcerpted.excerpt.clear();
const std::array<Post, 1> bare{ unexcerpted };
const auto listBare = Views::RenderPosts(bare);
Check(listBare.main.View().find("Read the full post") != std::string_view::npos,
"posts list: an excerptless post still offers the full post");
Post bodyless = p;
bodyless.body.clear();
const std::array<Post, 1> none{ bodyless };
const auto listNone = Views::RenderPosts(none);
Check(listNone.main.View().find("Read the full post") == std::string_view::npos,
"posts list: no read-more without a page to read");
Check(listNone.main.View().find(p.permalink) != std::string_view::npos,
"posts list: a pageless post still links its thread");
}
if (failures != 0) {
std::println(std::cerr, "{} check(s) failed", failures);
return 1;
}
return 0;
}

View file

@ -0,0 +1,102 @@
/*
catcrafts.net
Copyright (C) 2026 Catcrafts
The source code of this website is made available for viewing purposes only.
No permission is granted to copy, modify, distribute, or create derivative works.
*/
// Status codes, redirects, response headers, and the sitemap/feed documents —
// the things only a real request can show. A real 404 is the whole reason the
// backend exists: a client-side router cannot produce one.
import std;
import Catcrafts.E2eHarness;
using namespace Catcrafts::E2e;
int main(int argc, char** argv) {
TestServer srv(argv[1], 8210);
// ── status codes ──────────────────────────────────────────────────
for (std::string_view p : { "/", "/about", "/shop", "/shop/fp6-pmos", "/projects",
"/posts", "/demos", "/demos/raytracer", "/financials",
"/legal/privacy", "/legal/terms", "/legal/imprint",
"/feed.xml", "/sitemap.xml", "/api/healthz" }) {
srv.CheckStatus(std::string(p), "200");
}
// Trailing slashes must normalise, not 404 or duplicate the canonical URL.
srv.CheckStatus("/projects/", "200");
srv.CheckStatus("/shop/", "200");
// A real 404, which a client-side router cannot produce — this is the
// whole reason the backend exists.
srv.CheckStatus("/nope", "404");
srv.CheckStatus("/shop/nope", "404");
srv.CheckStatus("/legal/nope", "404");
// A slug that cannot be one of ours is rejected before any lookup.
srv.CheckStatus("/shop/BAD--slug", "404");
srv.CheckStatus("/demos/nope", "404");
// A post slug that parsed but names nothing must be a real 404, or every
// typo and every retired post becomes an indexable empty page.
srv.CheckStatus("/posts/nope", "404");
srv.CheckStatus("/posts/BAD--slug", "404");
// The retired blog URLs are still in the wild; they must redirect, not 404.
srv.CheckStatus("/blog", "301");
srv.CheckStatus("/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.
srv.CheckStatus("/demo", "301");
// ── redirects ─────────────────────────────────────────────────────
{
const auto blog = srv.Get("/blog/hello-world");
const auto it = blog.headers.find("location");
Check(it != blog.headers.end() && it->second.starts_with("/posts"),
"/blog/* sends Location: /posts",
it == blog.headers.end() ? "no location header" : it->second);
}
{
const auto demo = srv.Get("/demo");
const auto it = demo.headers.find("location");
Check(it != demo.headers.end() && it->second.starts_with("/demos"),
"/demo sends Location: /demos",
it == demo.headers.end() ? "no location header" : it->second);
}
// ── headers ───────────────────────────────────────────────────────
srv.HeaderHas("/", "x-content-type-options", "nosniff", "nosniff on pages");
srv.HeaderHas("/", "cache-control", "public", "pages are cacheable");
srv.HeaderHas("/nope", "x-robots-tag", "noindex", "404 is noindex");
srv.HeaderHas("/feed.xml", "content-type", "application/atom", "feed content-type");
srv.HeaderHas("/sitemap.xml", "content-type", "application/xml", "sitemap content-type");
// ── sitemap and feed content ──────────────────────────────────────
srv.BodyHas("/sitemap.xml", "/shop/fp6-pmos", "sitemap lists the product");
srv.BodyHas("/sitemap.xml", "/about", "sitemap lists the about page");
srv.BodyHas("/sitemap.xml", "/legal/privacy", "sitemap lists the privacy page");
srv.BodyHas("/sitemap.xml", "/demos", "sitemap lists the demos page");
srv.BodyHas("/sitemap.xml", "/financials", "sitemap lists the financials page");
srv.BodyLacks("/sitemap.xml", "/blog", "sitemap does not advertise the redirect");
srv.BodyLacks("/sitemap.xml", "/order", "sitemap does not advertise order pages");
srv.BodyHas("/feed.xml", "<feed xmlns=\"http://www.w3.org/2005/Atom\">", "feed is Atom");
// ── abuse ─────────────────────────────────────────────────────────
srv.CheckStatus("/shop/fp6-pmos", "413", "POST",
"email=a%40b.example&name=" + std::string(20000, 'x')
+ "&street=x&postal=1&city=y&country=NL");
{
const auto json = srv.Post("/shop/fp6-pmos", "{}", "application/json");
Check(json.status == "415", "POST with a JSON content-type -> 415", json.status);
}
srv.CheckStatus("/shop/nope", "404", "POST",
"email=a%40b.example&name=Ada&street=x&postal=1&city=y&country=NL");
srv.CheckStatus("/projects", "405", "POST", "x=1");
// HEAD must not be a 500 or a body — some crawlers use it exclusively.
srv.CheckStatus("/", "200", "HEAD");
{
const auto head = srv.Head("/");
Check(head.body.empty(), "HEAD carries no body");
}
return Finish();
}

View file

@ -0,0 +1,219 @@
/*
catcrafts.net
Copyright (C) 2026 Catcrafts
The source code of this website is made available for viewing purposes only.
No permission is granted to copy, modify, distribute, or create derivative works.
*/
// The compiled-in catalogue and the schema.org identity graph. Content is
// code now; these assertions are the contract the shop pages rely on,
// checked against the actual shipped data.
import std;
import Catcrafts.Shared;
using namespace Catcrafts;
namespace {
int failures = 0;
void Check(bool ok, std::string_view what, std::string_view got = {}) {
if (ok) return;
++failures;
std::println(std::cerr, "FAIL: {}{}{}", what,
got.empty() ? "" : " got: ", got);
}
// ── the compiled-in catalogue ─────────────────────────────────────────
void CatalogueContract() {
using namespace Catcrafts::Money;
const auto& products = Content::Products();
Check(products.size() == 1, "content: one product");
if (products.size() == 1) {
const Product& pr = products[0];
Check(pr.slug == "fp6-pmos", "content: product slug");
// Coming-soon is the pre-launch state; launch flips it to
// "available" and this check keeps passing either way.
Check(pr.Buyable() || pr.ComingSoon(),
"content: product is buyable or deliberately coming soon");
Check(pr.variants.size() == 3, "content: three colours");
// Cost-plus pricing, derived in code: supplier + €50, exactly.
Check(pr.FindVariant("green") && pr.FindVariant("green")->priceInclMinor == 56330,
"content: green = 513.30 supplier + 50 markup");
Check(pr.FindVariant("black") && pr.FindVariant("black")->priceInclMinor == 56930,
"content: black = 519.30 supplier + 50 markup");
Check(pr.FindVariant("white") && pr.FindVariant("white")->priceInclMinor == 65488,
"content: white = 604.88 supplier + 50 markup");
Check(pr.FindVariant("mauve") == nullptr, "content: unknown colour is null");
Check(pr.priceInclMinor == 56330, "content: from-price is the cheapest variant");
Check(pr.CheapestVariant() && pr.CheapestVariant()->slug == "green",
"content: cheapest is green");
Check(pr.safetyNote.find("112") != std::string::npos
&& pr.safetyNote.find("not yet verified") != std::string::npos,
"content: emergency-calling safety warning present and honest");
Check(pr.warranty.find("TODO") == std::string::npos && pr.warranty.size() > 100,
"content: warranty is written, not a placeholder");
// The product page's schema.org record must parse with our own
// JSON parser and carry one variant Product per colour, each
// with its ONE offer — built from the same integers the
// checkout charges.
{
// The listing's shipping block is now carrier data, so the
// render needs a table. US is priced here on purpose: the
// carrier will happily quote it and the shop still must not
// advertise it.
const std::vector<ShipRates> feedTable{
{ "NL", { { 2000, 895 } } },
{ "DE", { { 2000, 995 } } },
{ "GB", { { 2000, 2450 } } },
{ "US", { { 2000, 1794 } } },
};
auto pp = Views::RenderProduct(pr, Rates{}, feedTable);
auto ld = Json::Parse(pp.meta.jsonLd);
bool variantsOk = false;
if (ld && ld->IsObject()) {
if (const Json::Value* v = ld->Find("hasVariant");
v && v->IsArray() && v->array.size() == pr.variants.size()) {
variantsOk = true;
for (const Json::Value& node : v->array) {
const Json::Value* o = node.Find("offers");
variantsOk = variantsOk && node.Str("@type") == "Product"
&& o && o->IsObject();
}
}
}
Check(ld && ld->IsObject() && ld->Str("@type") == "ProductGroup" && variantsOk,
"schema: product JSON-LD parses, one variant per colour");
// Merchant-grade fields: shipping, returns, sku, group id —
// what Merchant Center's website-crawl feed reads at launch
// (productGroupID is its item_group_id).
Check(pp.meta.jsonLd.find("OfferShippingDetails") != std::string::npos
&& pp.meta.jsonLd.find("MerchantReturnPolicy") != std::string::npos
&& pp.meta.jsonLd.find("\"sku\"") != std::string::npos
&& pp.meta.jsonLd.find("\"productGroupID\"") != std::string::npos,
"schema: variants carry shipping, returns, sku and group id");
// The published rates ARE the carrier's, at one unit's weight.
Check(pp.meta.jsonLd.find("\"8.95\"") != std::string::npos
&& pp.meta.jsonLd.find("\"24.50\"") != std::string::npos,
"schema: shipping rates come from the carrier table");
Check(pp.meta.jsonLd.find("\"17.94\"") == std::string::npos
&& pp.meta.jsonLd.find("\"US\"") == std::string::npos,
"schema: a refused destination is never advertised, priced or not");
// No table: no shipping claim. The listing loses the merchant
// block rather than inventing a rate — the whole point of
// dropping the zone fallback.
auto bare = Views::RenderProduct(pr, Rates{});
Check(bare.meta.jsonLd.find("OfferShippingDetails") == std::string::npos
&& bare.meta.jsonLd.find("MerchantReturnPolicy") == std::string::npos,
"schema: with no carrier table the offer publishes no shipping");
Check(Json::Parse(bare.meta.jsonLd).has_value()
&& bare.meta.jsonLd.find("\"productGroupID\"") != std::string::npos,
"schema: and the rest of the record still parses");
}
}
Check(!Content::Projects().empty(), "content: projects present");
Check(Content::LegalPages().size() == 3, "content: three legal pages");
Check(Content::AboutPage().sections.size() >= 3
&& !Content::AboutPage().sections[0].body.empty()
&& Content::AboutPage().sections[0].body[0].find("Jorijn van der Graaf")
!= std::string::npos,
"content: about page names the founder");
Check(!Content::Demos().empty(), "content: demos present");
}
// ── the identity graph ────────────────────────────────────────────────
// "Catcrafts" is two common words with no space, so it competes with a
// decade of kids' craft blogs, Etsy and a Minecraft server on the
// singular domain. The way out is not prose: it is one registered entity
// that every page points at by @id. Those joins are worth asserting
// because breaking one is silent — a typo'd @id still renders, still
// validates as JSON-LD, and still splits the graph back into three
// same-named strangers, which is the exact failure this markup exists to
// prevent.
void IdentityGraph() {
constexpr std::string_view kOrgId = "https://catcrafts.net/#organization";
auto home = Views::RenderHome(Content::Projects(), std::span<const Post>{});
auto ld = Json::Parse(home.meta.jsonLd);
const Json::Value* org = nullptr;
const Json::Value* site = nullptr;
if (ld && ld->IsObject()) {
if (const Json::Value* g = ld->Find("@graph"); g && g->IsArray()) {
for (const Json::Value& node : g->array) {
if (node.Str("@type") == "Organization") org = &node;
if (node.Str("@type") == "WebSite") site = &node;
}
}
}
Check(org && site, "schema: home graph parses, carries Organization and WebSite");
if (org && site) {
Check(org->Str("@id") == kOrgId, "schema: organization node is identified");
Check(site->Str("@id") == "https://catcrafts.net/#website",
"schema: website node is identified");
// The join that makes two nodes one entity rather than two.
const Json::Value* publisher = site->Find("publisher");
Check(publisher && publisher->IsObject() && publisher->Str("@id") == kOrgId,
"schema: website is published by the organization node");
// The navigational query "catcrafts" is answered from the site
// entity, so the spellings people type belong on it.
const Json::Value* alt = site->Find("alternateName");
Check(alt && alt->IsArray() && !alt->array.empty(),
"schema: website carries the spellings people type");
// Registry numbers, typed. These are the part no name-twin can
// produce — each one is checkable against a public register,
// which also means a wrong value is worse than no value.
bool kvk = false, vat = false, eori = false;
if (const Json::Value* ids = org->Find("identifier"); ids && ids->IsArray()) {
for (const Json::Value& id : ids->array) {
if (id.Str("propertyID") == "KVK") kvk = id.Str("value") == "78437059";
if (id.Str("propertyID") == "VAT") vat = id.Str("value") == "NL003329281B38";
if (id.Str("propertyID") == "EORI") eori = id.Str("value") == "NL1900095326";
}
}
Check(kvk && vat && eori, "schema: KVK, VAT and EORI present and exact");
// The name-twin guard. "Cat Crafts" with a space is the generic
// craft phrase owned by everyone else; claiming it as an alternate
// name argues for merging this entity into the corpus it needs to
// stay distinct from.
Check(home.meta.jsonLd.find("Cat Crafts") == std::string::npos,
"schema: the spaced generic is not claimed as a brand name");
}
// Cross-page joins: both must name the SAME @id the home page defines.
auto about = Views::RenderAbout(Content::AboutPage());
Check(about.meta.jsonLd.find(kOrgId) != std::string::npos,
"schema: about joins the founder to the organization node");
if (!Content::Products().empty()) {
auto pp = Views::RenderProduct(Content::Products()[0], Rates{});
Check(pp.meta.jsonLd.find(kOrgId) != std::string::npos,
"schema: offers are sold by the organization node");
}
// The person join, same mechanism in the other direction: home's
// founder and about's mainEntity must name one Person node, or "who
// founded Catcrafts" splits into two same-named strangers too.
constexpr std::string_view kPersonId = "https://catcrafts.net/about#person";
Check(home.meta.jsonLd.find(kPersonId) != std::string::npos
&& about.meta.jsonLd.find(kPersonId) != std::string::npos,
"schema: founder and about name one Person node");
}
} // namespace
int main() {
CatalogueContract();
IdentityGraph();
if (failures != 0) {
std::println(std::cerr, "{} check(s) failed", failures);
return 1;
}
return 0;
}

View file

@ -0,0 +1,151 @@
/*
catcrafts.net
Copyright (C) 2026 Catcrafts
The source code of this website is made available for viewing purposes only.
No permission is granted to copy, modify, distribute, or create derivative works.
*/
// 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. Shop pages carry exactly ONE pinned-down inline script
// (the timezone price hint); everything else ships none at all.
import std;
import Catcrafts.E2eHarness;
using namespace Catcrafts::E2e;
namespace {
// Visible prose only: strip tags, so an instance name inside an href (a
// post's own permalink necessarily contains one) does not trip the check.
std::string StripTags(std::string_view html) {
std::string out;
out.reserve(html.size());
bool inTag = false;
for (char c : html) {
if (c == '<') inTag = true;
else if (c == '>') { inTag = false; out.push_back(' '); }
else if (!inTag) out.push_back(c);
}
return out;
}
std::string Lower(std::string s) {
for (char& c : s) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
return s;
}
} // namespace
int main(int argc, char** argv) {
TestServer srv(argv[1], 8211);
// ── the no-JavaScript guarantee ───────────────────────────────────
srv.BodyHas("/projects", "imsd", "/projects has content in the HTML");
srv.BodyHas("/projects", "<title>Projects", "/projects has a real title");
srv.BodyLacks("/projects", "<script", "/projects ships no script at all");
srv.BodyLacks("/legal/privacy", "<script", "/legal/privacy ships no script");
srv.BodyHas("/financials", "<title>Financials", "/financials has a real title");
srv.BodyLacks("/financials", "<script", "/financials ships no script");
// Placeholders are dev-only markers; one reaching production is a content
// bug (an imprint that says PLACEHOLDER once shipped exactly that way).
for (std::string_view pg : { "/legal/privacy", "/legal/terms", "/legal/imprint",
"/shop/fp6-pmos", "/financials" }) {
srv.BodyLacks(std::string(pg), "PLACEHOLDER",
std::format("{} ships no placeholder markers", pg));
}
// 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 (std::string_view pg : { "/shop", "/shop/fp6-pmos" }) {
const std::string body = srv.Body(std::string(pg));
const std::size_t n = CountOccurrences(body, "<script>");
Check(n == 1,
std::format("{} carries exactly one executable script (the price hint)", pg),
std::format("expected 1 bare <script>, got {}", n));
const std::regex srcTag(R"(<script[^>]*src=)");
Check(!std::regex_search(body, srcTag),
std::format("{} script is inline, not external", pg));
const std::size_t open = body.find("<script>");
const std::size_t close = body.find("</script>", open);
std::string_view inlineScript;
if (open != std::string::npos && close != std::string::npos) {
inlineScript = std::string_view(body).substr(open, close - open);
}
bool network = false;
for (std::string_view api : { "fetch", "XMLHttpRequest", "WebSocket",
"navigator.sendBeacon" }) {
network = network || inlineScript.find(api) != std::string_view::npos;
}
Check(!network, std::format("{} script makes no network calls", pg));
}
srv.BodyHas("/shop/fp6-pmos", "cc-noneu", "price hint tags the non-EU outcome");
srv.BodyHas("/shop/fp6-pmos", "cc-eu", "price hint tags the confirmed-EU outcome too");
// The renderer loads only where a demo entry declares needsWasm — the
// demo LIST is a content page and must stay free of it.
srv.BodyLacks("/demos", "<script", "/demos itself ships no script");
srv.BodyHas("/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.
{
const std::size_t roots =
CountOccurrences(srv.Body("/demos/raytracer"), "id=\"catcrafts-root\"");
Check(roots == 1, "raytracer page has exactly one chrome root",
std::format("expected 1, got {}", roots));
}
// ── 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.
srv.BodyHas("/demos/raytracer", "name=\"cc-ssr\"", "SSR marker present for head.js to detect");
srv.BodyHas("/demos/raytracer", "<title>Real-time ray tracer",
"demo page keeps its route-specific title");
{
const std::string body = srv.Body("/demos/raytracer");
for (std::string_view probe : { "rel=\"stylesheet\"", "rel=\"icon\"",
"name=\"viewport\"" }) {
const std::size_t n = CountOccurrences(body, probe);
Check(n == 1, std::format("demo page has exactly one {}", probe),
std::format("expected 1, got {}", n));
}
}
// 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.
srv.BodyLacks("/posts", "<base", "/posts has no base tag");
srv.BodyLacks("/shop/fp6-pmos", "<base", "/shop/<slug> has no base tag");
// ── home page actions ─────────────────────────────────────────────
srv.BodyHas("/", "Browse projects", "home links to projects");
srv.BodyHas("/", "Browse shop", "home links to the shop");
srv.BodyLacks("/", "ray tracer", "home no longer pushes the ray tracer");
// ── 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.
for (std::string_view pg : { "/", "/posts", "/shop" }) {
const std::string prose = Lower(StripTags(srv.Body(std::string(pg))));
Check(prose.find("ani.social") == std::string::npos,
std::format("{} names no specific instance in visible text", pg));
}
srv.BodyHas("/posts", "fediverse", "/posts refers to the fediverse generally");
// The fediverse account is not advertised at all — only individual posts are.
srv.BodyLacks("/", "/u/", "footer does not link a fediverse profile");
srv.BodyLacks("/posts", "/u/", "/posts links no account profile, only threads");
return Finish();
}

View file

@ -0,0 +1,236 @@
/*
catcrafts.net
Copyright (C) 2026 Catcrafts
The source code of this website is made available for viewing purposes only.
No permission is granted to copy, modify, distribute, or create derivative works.
*/
// The form layer: urlencoded parsing, the email/country shape checks, and
// checkout validation — the gate every order submission passes through.
import std;
import Catcrafts.Shared;
using namespace Catcrafts;
namespace {
int failures = 0;
void Check(bool ok, std::string_view what, std::string_view got = {}) {
if (ok) return;
++failures;
std::println(std::cerr, "FAIL: {}{}{}", what,
got.empty() ? "" : " got: ", got);
}
} // namespace
int main() {
using namespace Catcrafts::Form;
// ── urlencoded parsing ────────────────────────────────────────────
auto parse = [](std::string_view b) { return ParseUrlEncoded(b); };
auto f = parse("email=a%40b.example&country=NL");
Check(f.has_value(), "form: basic body parses");
if (f) {
Check(f->Get("email") == "a@b.example", "form: %40 decodes to @");
Check(f->Get("country") == "NL", "form: second field");
Check(f->Get("missing").empty(), "form: absent field is empty");
Check(!f->Has("missing"), "form: Has() distinguishes absent");
}
Check(parse("a=1&&b=2")->Size() == 2, "form: empty segment tolerated");
Check(parse("a=1&")->Size() == 1, "form: trailing & tolerated");
Check(parse("flag")->Has("flag"), "form: valueless key present");
Check(parse("")->Size() == 0, "form: empty body");
Check(parse("q=hello+world")->Get("q") == "hello world", "form: + is space");
Check(parse("q=a%2Bb")->Get("q") == "a+b", "form: %2B is a literal plus");
Check(parse("n=caf%C3%A9")->Get("n") == "café", "form: utf-8 percent-decoding");
Check(parse("n=100%")->Get("n") == "100%", "form: malformed escape passes through");
Check(parse("n=%zz")->Get("n") == "%zz", "form: non-hex escape passes through");
// A field name is not allowed to be empty — "=x" is malformed, not a field.
Check(!parse("=x").has_value(), "form: empty field name rejected");
// Oversized input must be refused outright rather than truncated: acting on
// half a form is worse than refusing it.
Check(!parse(std::string(kMaxBodyBytes + 1, 'a')).has_value(), "form: oversized body rejected");
Check(!parse("a=" + std::string(kMaxFieldBytes + 1, 'x')).has_value(), "form: oversized field rejected");
// ── email shape ───────────────────────────────────────────────────
Check(LooksLikeEmail("a@b.example"), "email: minimal");
Check(LooksLikeEmail("first.last+tag@sub.domain.example"), "email: tagged, subdomain");
Check(!LooksLikeEmail("no-at-sign"), "email: no @");
Check(!LooksLikeEmail("@domain.example"), "email: empty local part");
Check(!LooksLikeEmail("user@"), "email: empty domain");
Check(!LooksLikeEmail("a@b@c.example"), "email: two @");
Check(!LooksLikeEmail("user@dotless"), "email: dotless domain");
Check(!LooksLikeEmail("user@.example"), "email: domain starts with dot");
Check(!LooksLikeEmail("a b@c.example"), "email: embedded space");
// Header-injection characters must never survive into anything that later
// builds an email envelope.
Check(!LooksLikeEmail("a@b.example\nBcc: x@y.example"), "email: newline rejected");
Check(!LooksLikeEmail("a@b.example\r\nSubject: x"), "email: CRLF rejected");
Check(!LooksLikeEmail("a,b@c.example"), "email: comma rejected");
Check(!LooksLikeEmail("<a@b.example>"), "email: angle brackets rejected");
Check(!LooksLikeEmail(std::string(250, 'a') + "@b.example"), "email: over 254 chars rejected");
// ── country code ──────────────────────────────────────────────────
Check(LooksLikeCountryCode("NL"), "country: uppercase");
Check(LooksLikeCountryCode("ca"), "country: lowercase accepted");
Check(!LooksLikeCountryCode("NLD"), "country: three letters rejected");
Check(!LooksLikeCountryCode("N"), "country: one letter rejected");
Check(!LooksLikeCountryCode("N1"), "country: digit rejected");
Check(!LooksLikeCountryCode(""), "country: empty rejected");
Check(Upper("nl") == "NL", "country: normalised to upper");
// ── trimming ──────────────────────────────────────────────────────
Check(Trim(" x ") == "x", "trim: spaces");
Check(Trim("\t\r\nx\n") == "x", "trim: tabs and newlines");
Check(Trim(" ").empty(), "trim: all whitespace");
// ── checkout validation ───────────────────────────────────────────
constexpr std::string_view kGoodOrder =
"email=a%40b.example&name=Ada&street=Main%20St%201&postal=1234AB&city=Delft&country=nl";
auto validate = [](std::string_view body) {
return ValidateCheckout(*ParseUrlEncoded(body));
};
auto good = validate(kGoodOrder);
Check(good.Ok(), "checkout: valid submission accepted");
Check(good.value.country == "NL", "checkout: country uppercased");
Check(good.value.street == "Main St 1", "checkout: street decoded and kept");
Check(!validate("name=Ada&street=x&postal=1&city=y&country=NL").Ok(),
"checkout: missing email rejected");
Check(!validate("email=a%40b.example&street=x&postal=1&city=y&country=NL").Ok(),
"checkout: missing name rejected");
Check(!validate("email=a%40b.example&name=Ada&postal=1&city=y&country=NL").Ok(),
"checkout: missing street rejected");
Check(!validate("email=a%40b.example&name=Ada&street=x&city=y&country=NL").Ok(),
"checkout: missing postal rejected");
Check(!validate("email=a%40b.example&name=Ada&street=x&postal=1&country=NL").Ok(),
"checkout: missing city rejected");
Check(!validate("email=a%40b.example&name=Ada&street=x&postal=1&city=y").Ok(),
"checkout: missing country rejected");
Check(!validate("email=nonsense&name=Ada&street=x&postal=1&city=y&country=NL").Ok(),
"checkout: bad email rejected");
// Every problem is reported at once — a form that surfaces one error per
// submission makes people resubmit to discover the rest.
Check(validate("email=&name=&street=&postal=&city=&country=").errors.size() == 6,
"checkout: errors accumulate");
// Honeypot: a filled hidden field means a bot. The message must not name
// the trap, or it teaches the next one how to pass.
auto pot = validate(std::string(kGoodOrder) + "&website=http%3A%2F%2Fspam");
Check(!pot.Ok(), "checkout: honeypot rejects");
Check(pot.errors.size() == 1 && pot.errors[0].message.find("honeypot") == std::string::npos
&& pot.errors[0].message.find("website") == std::string::npos,
"checkout: honeypot failure does not name the trap");
Check(!validate("email=a%40b.example&name=" + std::string(200, 'x')
+ "&street=x&postal=1&city=y&country=NL").Ok(),
"checkout: overlong name rejected");
// Colour and quantity: shape checks here, catalogue checks in the handler.
Check(validate(std::string(kGoodOrder) + "&color=green&quantity=2").Ok(),
"checkout: colour and quantity accepted");
Check(validate(std::string(kGoodOrder) + "&quantity=2").value.quantity == 2,
"checkout: quantity parsed");
Check(validate(kGoodOrder).value.quantity == 1, "checkout: quantity defaults to 1");
Check(!validate(std::string(kGoodOrder) + "&quantity=0").Ok(),
"checkout: zero quantity rejected");
Check(validate(std::string(kGoodOrder) + "&quantity=9").Ok(),
"checkout: bulk quantity welcome");
Check(validate(std::string(kGoodOrder) + "&quantity=99").Ok(),
"checkout: the technical ceiling itself is fine");
Check(!validate(std::string(kGoodOrder) + "&quantity=100").Ok(),
"checkout: past the technical ceiling rejected");
Check(!validate(std::string(kGoodOrder) + "&quantity=two").Ok(),
"checkout: non-numeric quantity rejected");
Check(!validate(std::string(kGoodOrder) + "&color=" + std::string(40, 'x')).Ok(),
"checkout: oversized colour rejected");
// The payment choice. Absent is a form that offered none (one rail
// configured, or the no-JS fallback page) and the handler resolves it to
// bank — the validator's job is only to refuse a word it does not know
// rather than let it fall through to a default the buyer never picked.
Check(validate(kGoodOrder).value.payChoice.empty(),
"checkout: absent payment choice stays empty");
Check(validate(std::string(kGoodOrder) + "&pay=bank").value.payChoice
== Catcrafts::Form::kPayBank,
"checkout: bank choice parsed");
Check(validate(std::string(kGoodOrder) + "&pay=crypto").value.payChoice
== Catcrafts::Form::kPayCrypto,
"checkout: crypto choice parsed");
{
auto bogus = validate(std::string(kGoodOrder) + "&pay=free");
Check(!bogus.Ok(), "checkout: unknown payment choice rejected");
Check(bogus.errors.size() == 1 && bogus.errors[0].field == "pay",
"checkout: the payment refusal hangs off the payment field");
}
// Destinations the shop refuses. Well-formed, real country codes — the
// refusal is policy, so it has to survive every spelling the form accepts,
// and it must not spill onto other non-EU destinations.
auto withCountry = [&](std::string_view cc) {
return validate("email=a%40b.example&name=Ada&street=x&postal=1&city=y&country="
+ std::string(cc));
};
Check(!withCountry("US").Ok(), "checkout: US refused");
Check(!withCountry("CA").Ok(), "checkout: CA refused");
Check(!withCountry("us").Ok(), "checkout: lowercase US refused too");
Check(withCountry("GB").Ok(), "checkout: other non-EU destinations still sell");
Check(withCountry("NL").Ok(), "checkout: EU unaffected");
{
auto us = withCountry("US");
Check(us.errors.size() == 1 && us.errors[0].field == "country",
"checkout: refusal is a country error, nothing else");
Check(us.errors[0].message == Catcrafts::Form::kNoSaleMessage,
"checkout: refusal says where the shop does not sell");
Check(us.value.country == "US", "checkout: refused country echoed back");
}
// The shipping refusals. These are templates rather than plain strings
// because the buy page fills the same ones client-side, so the substitution
// has to work on both {cc} and {n} — a template that silently kept its
// placeholder would ship "up to {n} per order" to a real buyer.
{
const std::string none = NoShippingMessage("BR");
Check(none.find("BR") != std::string::npos
&& none.find("{cc}") == std::string::npos,
"shipping copy: the uncovered-country message names the country");
const std::string heavy = TooHeavyMessage("JP", 3);
Check(heavy.find("JP") != std::string::npos && heavy.find("3") != std::string::npos
&& heavy.find("{n}") == std::string::npos,
"shipping copy: the too-heavy message names the country and the limit");
const std::string nofit = TooHeavyMessage("JP", 0);
Check(nofit.find("JP") != std::string::npos
&& nofit.find("up to") == std::string::npos,
"shipping copy: with nothing fitting it does not promise a quantity");
Check(FillShipMessage("{cc} {n} {cc}", "NL", 2) == "NL 2 NL",
"shipping copy: every placeholder is filled, not just the first");
// Both messages must offer the way out, since the shop is refusing
// business it would otherwise take.
Check(none.find("orders@catcrafts.net") != std::string::npos
&& heavy.find("orders@catcrafts.net") != std::string::npos
&& nofit.find("orders@catcrafts.net") != std::string::npos,
"shipping copy: every refusal names a human to email");
}
// A rejected field must still come back, or the visitor has to retype the
// one thing they got wrong — the fastest way to lose a submission.
auto rejected = validate("email=notanemail&name=Ada&street=Main%201&postal=1&city=y&country=NLD");
Check(!rejected.Ok(), "checkout: invalid pair rejected");
Check(rejected.value.email == "notanemail", "checkout: invalid email echoed back");
Check(rejected.value.country == "NLD", "checkout: invalid country echoed back as typed");
Check(rejected.value.name == "Ada", "checkout: valid sibling field preserved");
if (failures != 0) {
std::println(std::cerr, "{} check(s) failed", failures);
return 1;
}
return 0;
}

View file

@ -0,0 +1,338 @@
/*
catcrafts.net
Copyright (C) 2026 Catcrafts
The source code of this website is made available for viewing purposes only.
No permission is granted to copy, modify, distribute, or create derivative works.
*/
// The black-box test harness: spawns the REAL catcrafts-server binary on a
// scratch port with a temporary orders file and the FAKE payment rails, so a
// suite never touches real data, never dials Mollie, and needs no setup.
//
// This is the C++ port of what tools/e2e.sh used to set up in shell. Each
// suite is its own process (crafter-build runs them in PARALLEL), so every
// suite must use a UNIQUE port — see the AddTest declarations in project.cpp.
//
// Compiled into each suite via the AddTest(name, interfaces) overload rather
// than linked as a library: the harness is test scaffolding, not product
// code, and this keeps it out of every shipped artifact.
module;
#include <fcntl.h>
#include <signal.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <unistd.h>
export module Catcrafts.E2eHarness;
import std;
import Crafter.Network;
namespace fs = std::filesystem;
export namespace Catcrafts::E2e {
inline int failures = 0;
inline void Check(bool ok, std::string_view what, std::string_view got = {}) {
if (ok) return;
++failures;
std::println(std::cerr, "FAIL: {}{}{}", what,
got.empty() ? "" : " got: ", got);
}
inline int Finish() {
if (failures != 0) {
std::println(std::cerr, "{} check(s) failed", failures);
return 1;
}
return 0;
}
inline void WriteFile(const fs::path& p, std::string_view content) {
std::ofstream(p, std::ios::binary) << content;
}
inline std::string ReadFile(const fs::path& p) {
std::ifstream in(p, std::ios::binary);
if (!in) return {};
std::ostringstream buf;
buf << in.rdbuf();
return buf.str();
}
inline std::size_t CountOccurrences(std::string_view haystack, std::string_view needle) {
if (needle.empty()) return 0;
std::size_t n = 0;
for (std::size_t pos = haystack.find(needle); pos != std::string_view::npos;
pos = haystack.find(needle, pos + needle.size())) {
++n;
}
return n;
}
// The shipping rate table. Shipping has no compiled-in fallback — the carrier
// table is the only source of prices — so without this file every checkout
// correctly refuses and the whole order suite would be testing the refusal
// path by accident.
//
// This is byte-for-byte the cache the daily Sendcloud refresh writes, so the
// suites drive the production lookup with no test-only hook that could drift
// from it: country -> [[maxWeightGrams, consumerCents], ...], prices already
// VAT-inclusive (the gross-up happens at fetch, not at load).
//
// The single-unit rates are the €15 / €25 / €55 the totals assert. The second
// band exists so the too-heavy refusal has a real ceiling to hit:
// 10 kg / 700 g per unit = 14 units per parcel.
inline constexpr std::string_view kShippingFixture =
R"({"method":"e2e fixture","fetched_at":"2026-01-01T00:00:00Z","per_country":{)"
"\n"
R"("NL":[[2000,1500],[10000,2900]],)"
"\n"
R"("DE":[[2000,2500],[10000,4200]],)"
"\n"
R"("GB":[[2000,5500],[10000,7900]]}})"
"\n";
struct ServerOptions {
// BOTH slots on the fake rail, so the suites cover the payment CHOICE as
// well as the lifecycle. Which rail is behind each slot is exactly the
// part these tests should not care about. Both fakes share one marker
// file, so touching it settles whichever orders are outstanding.
std::vector<std::string> extraArgs = { "--rail=fake", "--crypto-rail=fake-crypto" };
// An ephemeral GPG key so invoice signing runs the REAL signing path and
// the suite can verify the signature (checkout suite only — it costs a
// keygen).
bool gpg = false;
// A fake sendmail, so the mailer's REAL path — build the MIME message,
// attach the signed invoice, shell out — runs with zero network. Each
// accepted message lands as its own mail-<n>.eml.
bool mailer = false;
// Extra environment for the server (e.g. BUNQ_CALLBACK_SECRET).
std::vector<std::pair<std::string, std::string>> env;
};
class TestServer {
public:
TestServer(std::string binary, std::uint16_t port, ServerOptions options = {})
: port_(port) {
work_ = fs::temp_directory_path()
/ std::format("catcrafts-e2e-{}-{}", port, ::getpid());
std::error_code ec;
fs::remove_all(work_, ec);
fs::create_directories(work_);
orders_ = work_ / "orders.jsonl";
// 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 the
// suites assert.
for (const char* v : { "MOLLIE_API_KEY", "EURC_CHAINS", "EURC_POOL",
"SENDCLOUD_PUBLIC_KEY", "SENDCLOUD_SECRET_KEY",
"SENDCLOUD_METHOD", "BUNQ_CALLBACK_SECRET",
"INVOICE_GPG_KEY", "MAIL_COMMAND", "MAIL_FROM" }) {
::unsetenv(v);
}
WriteFile(fs::path(orders_.string() + ".shipping.json"), kShippingFixture);
if (options.gpg) {
const fs::path gnupg = work_ / "gnupg";
fs::create_directories(gnupg);
fs::permissions(gnupg, fs::perms::owner_all, fs::perm_options::replace);
::setenv("GNUPGHOME", gnupg.c_str(), 1);
// gpg is declared via Requires("tool:gpg") in project.cpp, so a
// missing binary skips the suite before this ever runs; an error
// HERE is a real failure and should fail loudly.
if (std::system("gpg --batch --passphrase '' --quick-gen-key "
"'Catcrafts e2e <invoices@e2e.invalid>' "
"default default never >/dev/null 2>&1") != 0) {
std::println(std::cerr, "e2e: could not create a GPG key");
std::exit(1);
}
::setenv("INVOICE_GPG_KEY", "invoices@e2e.invalid", 1);
}
if (options.mailer) {
// The mailer sends sequentially from one thread, so the count-up
// cannot race itself.
const fs::path sendmail = work_ / "sendmail";
WriteFile(sendmail, std::format(
"#!/bin/sh\n"
"n=1\n"
"while [ -e \"{0}/mail-$n.eml\" ]; do n=$((n + 1)); done\n"
"cat > \"{0}/mail-$n.eml\"\n", work_.string()));
fs::permissions(sendmail,
fs::perms::owner_all | fs::perms::group_read
| fs::perms::others_read,
fs::perm_options::replace);
::setenv("MAIL_COMMAND", sendmail.c_str(), 1);
::setenv("MAIL_FROM", "Catcrafts <info@catcrafts.net>", 1);
}
for (const auto& [name, value] : options.env) {
::setenv(name.c_str(), value.c_str(), 1);
}
std::vector<std::string> argv = {
std::move(binary), "--serve", std::to_string(port),
std::format("--orders={}", orders_.string()),
};
for (const std::string& a : options.extraArgs) argv.push_back(a);
Spawn(argv);
WaitUntilUp();
}
~TestServer() {
if (pid_ > 0) {
::kill(pid_, SIGTERM);
int status = 0;
::waitpid(pid_, &status, 0);
}
std::error_code ec;
fs::remove_all(work_, ec);
}
TestServer(const TestServer&) = delete;
TestServer& operator=(const TestServer&) = delete;
const fs::path& Work() const { return work_; }
const fs::path& Orders() const { return orders_; }
std::string OrdersText() const { return ReadFile(orders_); }
// One connection per request, like one curl invocation per check in the
// shell version: the suites assert responses, not connection reuse (the
// keep-alive path has its own tests in Crafter.Network).
Crafter::HTTPResponse Request(Crafter::HTTPRequest request) {
request.scheme = "http";
Crafter::ClientHTTP1 client("127.0.0.1", port_);
return client.Send(std::move(request));
}
Crafter::HTTPResponse Get(std::string path) {
return Request(Crafter::CreateRequestHTTP("GET", std::move(path), "127.0.0.1"));
}
Crafter::HTTPResponse Head(std::string path) {
return Request(Crafter::CreateRequestHTTP("HEAD", std::move(path), "127.0.0.1"));
}
// A form post, exactly what a browser (and `curl -d`) sends.
Crafter::HTTPResponse Post(std::string path, std::string body,
std::string contentType = "application/x-www-form-urlencoded") {
return Request(Crafter::CreateRequestHTTP(
"POST", std::move(path), "127.0.0.1",
{{ "content-type", std::move(contentType) }}, std::move(body)));
}
std::string Body(std::string path) { return Get(std::move(path)).body; }
// ── the e2e.sh assertion primitives ──────────────────────────────
// status <path> <expected> [method] [body]
void CheckStatus(std::string path, std::string_view want,
std::string_view method = "GET", std::string body = {}) {
Crafter::HTTPResponse r;
if (method == "POST") r = Post(path, std::move(body));
else if (method == "HEAD") r = Head(path);
else r = Get(path);
Check(r.status == want,
std::format("{} {} -> {}", method, path, want),
r.status);
}
void BodyHas(std::string path, std::string_view needle, std::string_view label) {
Check(Body(std::move(path)).find(needle) != std::string::npos,
label, std::format("missing: {}", needle));
}
void BodyLacks(std::string path, std::string_view needle, std::string_view label) {
Check(Body(std::move(path)).find(needle) == std::string::npos,
label, std::format("unexpectedly present: {}", needle));
}
// header_has <path> <header> <value substring>. The parser lowercases
// field names and the values the suites probe are already lowercase on
// the wire, so a plain find replaces the shell version's grep -iE.
void HeaderHas(std::string path, std::string_view header,
std::string_view valuePart, std::string_view label) {
const auto r = Get(std::move(path));
const auto it = r.headers.find(std::string(header));
Check(it != r.headers.end() && it->second.find(valuePart) != std::string::npos,
label,
it == r.headers.end() ? std::format("no {} header", header) : it->second);
}
// Poll until the body of `path` contains `needle`, for the settle-time
// checks (reconciler cadence, mailer sweeps). Returns the final body.
std::string WaitForBody(const std::string& path, std::string_view needle,
std::int32_t tries = 40) {
for (std::int32_t i = 0; i < tries; ++i) {
std::string body = Body(path);
if (body.find(needle) != std::string::npos) return body;
std::this_thread::sleep_for(std::chrono::milliseconds(250));
}
return Body(path);
}
// Open shop or coming-soon? The pricing blob (data-cc) exists only on the
// real order form, so its presence is the probe.
bool ShopOpen() { return Body("/shop/fp6-pmos").find("data-cc=") != std::string::npos; }
private:
void Spawn(const std::vector<std::string>& argv) {
log_ = work_ / "server.log";
std::vector<char*> cargv;
cargv.reserve(argv.size() + 1);
for (const std::string& a : argv) cargv.push_back(const_cast<char*>(a.c_str()));
cargv.push_back(nullptr);
pid_ = ::fork();
if (pid_ == 0) {
const int fd = ::open(log_.c_str(), O_CREAT | O_WRONLY | O_TRUNC, 0644);
if (fd >= 0) {
::dup2(fd, 1);
::dup2(fd, 2);
::close(fd);
}
::execv(cargv[0], cargv.data());
::_exit(127);
}
if (pid_ < 0) {
std::println(std::cerr, "e2e: fork failed");
std::exit(1);
}
}
// 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.
void WaitUntilUp() {
for (std::int32_t i = 0; i < 100; ++i) {
// The child exiting early (bad flag, port in use) must not turn
// into a 10-second wait on a listener that will never appear.
int status = 0;
if (::waitpid(pid_, &status, WNOHANG) == pid_) {
pid_ = -1;
break;
}
try {
if (Get("/api/healthz").status == "200") return;
} catch (...) {
// Not listening yet.
}
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
std::println(std::cerr, "e2e: server did not come up on {}", port_);
std::println(std::cerr, "{}", ReadFile(log_));
std::exit(1);
}
std::uint16_t port_ = 0;
pid_t pid_ = -1;
fs::path work_;
fs::path orders_;
fs::path log_;
};
} // namespace Catcrafts::E2e

File diff suppressed because it is too large Load diff