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

@ -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