/* 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 goods half addresses whatever priced product the compiled catalogue // lists first — slug, default colour, boxed weight — and derives every expected // figure from that record and the harness's carrier table with the till's own // Money arithmetic. It runs only while that product is OPEN; while coming-soon // it asserts the closed behaviour instead, and while the catalogue lists no // goods at all (as since 2026-09-05) it prints a note and is skipped — listing // one re-arms it with no edit here. The validation half runs in both shop // states on purpose: validation happens before the coming-soon check, and that // ordering is exactly what it pins. The donation lifecycle runs regardless. 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 LedgerLines(TestServer& srv) { std::vector 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 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; } // The carrier table the harness hands the server (kShippingFixture), as the // ladders the totals below are derived from: one parcel under 2 kg ships for // €15 at home and €55 to Switzerland, and the 10 kg band is what caps a parcel. const std::vector kNl{ { 2000, 1500 }, { 10000, 2900 } }; const std::vector kCh{ { 2000, 5500 }, { 10000, 7900 } }; // What the till charges for `quantity` units at `unit` cents to `cc`: the // parcel weighs quantity × boxed weight, the carrier prices that parcel, and // ComputeTotals nets the LINE for an export. The same three calls the // checkout handler makes, so a figure asserted here is one the shop derives, // not one anybody typed. std::int64_t Total(const Product& pr, std::int64_t unit, std::int64_t quantity, std::span ladder, std::string_view cc) { const std::int64_t shipping = Money::RateFor(ladder, pr.shipWeightGrams * quantity); return Money::ComputeTotals(unit, quantity, shipping, cc).total; } void OpenShopLifecycle(TestServer& srv, const Product& pr) { const std::string shop = "/shop/" + pr.slug; const std::int64_t unit = pr.priceInclMinor; // the default (cheapest) colour const std::int64_t nlTotal = Total(pr, unit, 1, kNl, "NL"); const std::string nlEuro = Money::FormatEuro(nlTotal); // The one-parcel ceiling: the heaviest band any destination offers (10 kg // in the fixture) divided by the boxed unit weight. const std::int64_t maxUnits = Money::MaxUnitsFor(kNl, pr.shipWeightGrams); // ── 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, 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(std::format("\"total_minor\":{}", nlTotal)) != std::string::npos, "order stored: NL total is the default colour plus €15 home 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 : std::initializer_list{ "awaiting payment", "Resume payment", "CC-", "http-equiv=\"refresh\"", nlEuro }) { 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 the bank. srv.BodyHas(shop, "name=\"pay\"", "the form offers a payment choice"); srv.BodyHas(shop, "value=\"crypto\"", "crypto is one of the choices"); srv.BodyHas(shop, "value=\"bank\" checked", "bank is the pre-selected choice"); const std::string tokenCrypto = TokenOf(srv.Post(shop, 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 choice — // the bank copy on a crypto order would send someone looking for // iDEAL. The fake crypto slot renders instructions like the real // EURC rail, so the probe is the instructions block. (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("Pay with EURC") != std::string::npos, "the crypto order page renders the payment instructions"); 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, 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. CH because // it is the export destination that actually sells — North America is // refused on insurance, and GB waits on its e-waste registrations. const std::string tokenCh = TokenOf(srv.Post(shop, "email=ch%40example.org&name=Heidi&street=1%20Bahnhofstrasse&postal=8001&city=Zurich&country=CH")); Check(!tokenCh.empty(), "CH checkout issues an order"); if (!tokenCh.empty()) { const std::string page = srv.Body(std::format("/order/{}", tokenCh)); // Net of the default colour + €55 world shipping. Check(page.find(Money::FormatEuro(Total(pr, unit, 1, kCh, "CH"))) != 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("≈ CHF [0-9]+")), "export order shows the indicative CHF amount"); Check(page.find("indicative") != std::string::npos, "conversion is labelled indicative"); } // A two-unit export order in the dearest colour (or the only price, for a // colourless listing): the export net comes from the LINE total (unit × 2), // not per unit, plus one parcel's world shipping at twice the weight. const Variant* dear = nullptr; for (const Variant& v : pr.variants) { if (!dear || v.priceInclMinor > dear->priceInclMinor) dear = &v; } const std::int64_t dearUnit = dear ? dear->priceInclMinor : unit; const std::string tokenTwo = TokenOf(srv.Post(shop, "email=w%40example.org&name=W&street=X%201&postal=1&city=Y&country=CH&quantity=2" + (dear ? "&color=" + dear->slug : std::string{}))); Check(!tokenTwo.empty(), "a two-unit checkout issues an order"); if (!tokenTwo.empty()) { const std::string page = srv.Body(std::format("/order/{}", tokenTwo)); Check(page.find(Money::FormatEuro(Total(pr, dearUnit, 2, kCh, "CH"))) != std::string::npos, "two-unit export total nets the line, not the unit"); Check(page.find("Device × 2") != std::string::npos, "order page shows the quantity"); if (dear) { Check(page.find(dear->label) != std::string::npos, "order page names the colour"); } } // A colour we never listed must not buy anything, whatever the form claims. if (!pr.variants.empty()) { srv.CheckStatus(shop, "422", "POST", Good("&color=no-such-colour")); } srv.CheckStatus(shop, "422", "POST", Good("&quantity=100")); srv.CheckStatus(shop, "422", "POST", Good("&quantity=0")); // Quantity is a free input with a technical ceiling, not a dropdown — a // multi-unit order within the parcel is business, not fraud. const std::int64_t several = std::min(9, maxUnits); Check(several > 1 && !TokenOf(srv.Post(shop, Good(std::format("&quantity={}", several)))).empty(), std::format("a {}-unit order goes through", several)); srv.BodyHas(shop, "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. The input // advertises the BEST case across destinations; the per-country limit is // enforced on submit, below. srv.BodyHas(shop, std::format("max=\"{}\"", maxUnits), "quantity input carries the one-parcel ceiling"); // One order is one parcel. One unit past the ceiling is 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, Good(std::format("&quantity={}", maxUnits + 1))); Check(heavy.status == "422", "an over-weight order is refused", heavy.status); Check(heavy.body.find(std::format("up to {} per order", maxUnits)) != 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, 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 can't ship there") == std::string::npos, "an uncovered destination is not confused with a refused one"); const std::size_t before = LedgerLines(srv).size(); srv.Post(shop, 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 : std::initializer_list{ "BEGIN PGP SIGNED MESSAGE", "# Invoice ", "Customer number: ", "Chico Mendesring 256", "KVK 78437059", "NL003329281B38", "CC-", "VAT 21% (NL)", nlEuro }) { 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 -, 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 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 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 chMail; 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 (!tokenCh.empty() && mail.find(std::format("/order/{}", tokenCh)) != std::string::npos) { chMail = mail; } } Check(!nlMail.empty(), "a confirmation email links the NL order"); if (!nlMail.empty()) { for (std::string_view probe : std::initializer_list{ "To: e2e@example.org", "Subject: Catcrafts order CC-", "From: Catcrafts ", "MIME-Version: 1.0", nlEuro, "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(!chMail.empty() && chMail.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 paidIds; std::int64_t wantMinor = 0; std::size_t wantCount = 0; const std::vector 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, const Product& pr) { const std::string shop = "/shop/" + pr.slug; // 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, "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, const Product& pr) { const std::string shop = "/shop/" + pr.slug; srv.CheckStatus(shop, "422", "POST", "name=Ada&street=x&postal=1&city=y&country=NL"); // no email srv.CheckStatus(shop, "422", "POST", "email=nonsense&" + Good()); // bad email (dup keeps first) srv.CheckStatus(shop, "422", "POST", "email=a%40b.example&country=NL"); // missing address srv.CheckStatus(shop, "422", "POST", Good("&website=spam")); // honeypot // Destinations the shop refuses (Money::SellsTo). 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 gate being lost in a refactor. const std::size_t before = LedgerLines(srv).size(); srv.CheckStatus(shop, "422", "POST", "email=us%40example.org&name=Pat&street=1%20Main%20St&postal=43004&city=Columbus&country=US"); srv.CheckStatus(shop, "422", "POST", "email=ca%40example.org&name=Terry&street=1%20Bloor%20St&postal=M4W&city=Toronto&country=CA"); srv.CheckStatus(shop, "422", "POST", "email=us%40example.org&name=Pat&street=1%20Main%20St&postal=43004&city=Columbus&country=us"); // Sanctioned destinations (Money::SanctionedCountries) refuse through the // same always-on gate — this refusal is the law, so of all the checks in // this file it is the one that must survive every refactor. srv.CheckStatus(shop, "422", "POST", "email=ru%40example.org&name=Sasha&street=1%20Tverskaya&postal=125009&city=Moscow&country=RU"); srv.CheckStatus(shop, "422", "POST", "email=by%40example.org&name=Vanya&street=1%20Kastrychnitskaya&postal=220030&city=Minsk&country=BY"); srv.CheckStatus(shop, "422", "POST", "email=ru%40example.org&name=Sasha&street=1%20Tverskaya&postal=125009&city=Moscow&country=ru"); // Destinations off the shipping allow-list (Money::ShipsTo). DE and GB are // the pointed cases: the fixture PRICES both, so a rate exists and the parcel // is postable — the refusal is entirely the policy's, which is the whole // reason to assert these rather than a country the carrier never covered. // // Status only, like every other refusal in this function. The exact sentence // is asserted in ShouldValidateForms, where the validator is called directly: // this function runs in the coming-soon state too, and a closed shop renders // no order form for a field error to land in. What is worth proving over real // HTTP is that the refusal holds at all, in both states — which is what 422 // says here. srv.CheckStatus(shop, "422", "POST", "email=de%40example.org&name=Klaus&street=1%20Hauptstr&postal=10115&city=Berlin&country=DE"); srv.CheckStatus(shop, "422", "POST", "email=gb%40example.org&name=Terry&street=1%20Baker%20St&postal=W1U&city=London&country=GB"); srv.CheckStatus(shop, "422", "POST", "email=no%40example.org&name=Kari&street=1%20Karl%20Johans&postal=0154&city=Oslo&country=NO"); srv.CheckStatus(shop, "422", "POST", "email=tr%40example.org&name=Emre&street=1%20Istiklal&postal=34430&city=Istanbul&country=TR"); srv.CheckStatus(shop, "422", "POST", "email=br%40example.org&name=Ana&street=1%20Paulista&postal=01310&city=Sao%20Paulo&country=BR"); // And the default that makes an allow-list worth having: a well-formed code // nobody ever considered is refused without appearing on any list. srv.CheckStatus(shop, "422", "POST", "email=zz%40example.org&name=Sam&street=1%20Main&postal=0000&city=Nowhere&country=ZZ"); // 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 donation item is open in BOTH shop states — it is the soft opening the // goods wait behind, listed or not — so this whole lifecycle runs // unconditionally: create with a buyer-named amount, settle on the fake rail, // confirm there is no invoice and no VAT, and watch /financials book it under // donations rather than sales. void DonationLifecycle(TestServer& srv) { // ── validation ──────────────────────────────────────────────────── // The donation validator's refusals, over real HTTP. No address fields // exist to miss; the amount is the field that carries the rules. srv.CheckStatus("/shop/donation", "422", "POST", "email=a%40b.example"); // no amount srv.CheckStatus("/shop/donation", "422", "POST", "amount=nonsense"); srv.CheckStatus("/shop/donation", "422", "POST", "amount=0.50"); // below €1 srv.CheckStatus("/shop/donation", "422", "POST", "amount=10000.01"); // above €10k srv.CheckStatus("/shop/donation", "422", "POST", "amount=25&email=nonsense"); srv.CheckStatus("/shop/donation", "422", "POST", "amount=25&website=spam"); // honeypot srv.CheckStatus("/shop/donation", "422", "POST", "amount=25&pay=free"); { // A refused amount comes back in the form, like any rejected field. const auto refused = srv.Post("/shop/donation", "amount=0.50"); Check(refused.body.find("field__error") != std::string::npos, "a refused donation shows a field error"); Check(refused.body.find("€1 to €10000") != std::string::npos, "the amount refusal names the bounds"); } // ── the in-flight state ─────────────────────────────────────────── // Between the wallet's "success" and finality the rail reports the money // as SEEN but not yet evidence, and the page must say so — the first // live EURC payment proved that a buyer staring at "awaiting payment" // after their wallet said done is a support email in the making. The // fake rail's ".seen" file is that state's test handle. // // The rail must be UNPAID first. The open-shop half (when it runs) leaves // the paid marker behind, and a marker already present pays this donation // the moment it arrives — the seen state could then never show. Every // order placed before this point has already settled, so nothing else // notices the marker going. { std::error_code ec; std::filesystem::remove(std::filesystem::path( srv.Orders().string() + ".fake-paid"), ec); const std::string tokenSeen = TokenOf(srv.Post("/shop/donation", "amount=2&pay=crypto")); Check(!tokenSeen.empty(), "a crypto donation goes through"); if (!tokenSeen.empty()) { const std::string seenPath = std::format("/order/{}", tokenSeen); // Before anything is seen: the awaiting page owns the timing // expectation, so the gap between wallet and shop is explained // even to a buyer who never reloads. srv.BodyHas(seenPath, "typically follows in 10 to 25 minutes", "the awaiting page states the confirmation timing"); WriteFile(std::filesystem::path( srv.Orders().string() + ".fake-paid.seen"), ""); // The badge is where the buyer looks first, so it carries the // state itself: "awaiting payment" under a wallet that said // success reads as "your money did not arrive". "Detected" and // not "received": the shop has seen the transfer, it does not // yet trust it, and the badge should not outrun the rail. The // waiting half uses the words every exchange deposit screen has // taught buyers: "awaiting network confirmation". const std::string page = srv.WaitForBody( seenPath, ">payment detected, awaiting network confirmation<"); Check(page.find(">payment detected, awaiting network confirmation<") != std::string::npos, "the badge flips to detected-awaiting-confirmation in flight"); Check(page.find(">awaiting payment<") == std::string::npos, "the awaiting badge is replaced, not doubled"); Check(page.find("Thank you") == std::string::npos, "an in-flight payment is not yet confirmed as paid"); std::error_code ec; std::filesystem::remove(std::filesystem::path( srv.Orders().string() + ".fake-paid.seen"), ec); } } // ── a donation with no email at all ─────────────────────────────── // Identity is optional: the capability URL is the receipt. const auto created = srv.Post("/shop/donation", "amount=25"); const std::string token = TokenOf(created); Check(created.status == "303" && !token.empty(), "POST donation -> 303 straight to payment", created.status); { const std::string line = [&] { for (const std::string& l : LedgerLines(srv)) { if (l.find(std::format("\"id\":\"{}\"", token)) != std::string::npos && l.find("\"type\":\"order\"") != std::string::npos) { return l; } } return std::string{}; }(); Check(!line.empty(), "the donation order is on the ledger"); for (std::string_view probe : { "\"donation\":true", "\"total_minor\":2500", "\"shipping_minor\":0", "\"vat_included\":false", "\"product\":\"donation\"", "\"email\":\"\"" }) { Check(line.find(probe) != std::string::npos, std::format("donation ledger line has {}", probe)); } } const std::string orderPath = std::format("/order/{}", token); { const std::string page = srv.Body(orderPath); Check(page.find("€25") != std::string::npos, "donation order page shows the amount"); Check(page.find("Shipping") == std::string::npos, "donation order page has no shipping row"); } // ── the payment lands ───────────────────────────────────────────── // Same fake-rail marker as checkout (idempotent if the open-shop half // already created it). The paid state is a thank-you, not a dispatch // promise, and there is no invoice to download — not before, not after. const std::size_t invoicesBefore = CountOccurrences(srv.OrdersText(), "\"type\":\"invoice\""); srv.CheckStatus(std::format("/order/{}/invoice.md", token), "404"); WriteFile(std::filesystem::path(srv.Orders().string() + ".fake-paid"), ""); { const std::string page = srv.WaitForBody(orderPath, "Thank you"); Check(page.find("Thank you") != std::string::npos, "a paid donation says thank you"); Check(page.find("VAT 0%") != std::string::npos, "a paid donation states the 0% VAT treatment"); Check(page.find("Zero-rated export") == std::string::npos, "a donation is not worded as an export"); Check(page.find("invoice.md") == std::string::npos, "a paid donation offers no invoice download"); Check(page.find("prepared and tested") == std::string::npos, "a paid donation promises no dispatch"); } srv.CheckStatus(std::format("/order/{}/invoice.md", token), "404"); // Settle a moment: no invoice event may appear for a donation, ever. SettleUntil([&] { return srv.OrdersText().find(std::format("\"id\":\"{}\",\"status\":\"paid\"", token)) != std::string::npos; }); Check(CountOccurrences(srv.OrdersText(), "\"type\":\"invoice\"") == invoicesBefore, "a paid donation is assigned no invoice number"); // ── /financials books it under donations ────────────────────────── // Ledger-derived, same rule as the sales assertion: paid donation orders // sum into the donations pair, paid goods orders into sales, and no euro // sits in both. { std::set paidIds; const std::vector 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"))); } } std::int64_t wantSales = 0, wantDonations = 0; std::size_t wantSalesCount = 0, wantDonationCount = 0; for (const std::string& line : lines) { const auto event = Json::Parse(line); if (!event || !event->IsObject()) continue; if (event->Str("type") != "order" || !paidIds.contains(std::string(event->Str("id")))) { continue; } if (event->Bool("donation")) { wantDonations += event->Int("total_minor"); ++wantDonationCount; } else { wantSales += event->Int("total_minor"); ++wantSalesCount; } } const std::string page = srv.Body("/financials"); Check(wantDonationCount > 0, "at least one paid donation is on the ledger"); Check(page.find(std::format("data-fin-donations-count=\"{}\"", wantDonationCount)) != std::string::npos && page.find(std::format("data-fin-donations-minor=\"{}\"", wantDonations)) != std::string::npos, std::format("donation totals equal the ledger ({} donations, {} cents)", wantDonationCount, wantDonations)); Check(page.find(std::format("data-fin-sales-count=\"{}\"", wantSalesCount)) != std::string::npos && page.find(std::format("data-fin-sales-minor=\"{}\"", wantSales)) != std::string::npos, "sales totals exclude the donation"); } // ── the confirmation email ──────────────────────────────────────── // With an email given: a thank-you, no invoice attached (none exists). // Without one: silence — no address means the donor asked for nothing. const std::size_t mailsBefore = MailCount(srv); const std::string tokenMailed = TokenOf(srv.Post("/shop/donation", "amount=10&email=donor%40example.org")); Check(!tokenMailed.empty(), "a donation with an email goes through"); SettleUntil([&] { return MailCount(srv) > mailsBefore; }, 60); std::string donationMail; 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/{}", tokenMailed)) != std::string::npos) { donationMail = mail; } } Check(!donationMail.empty(), "a donation with an email gets a confirmation"); if (!donationMail.empty()) { for (std::string_view probe : { "To: donor@example.org", "Subject: Catcrafts donation CC-", "Thank you", "€10", "No VAT applies" }) { Check(donationMail.find(probe) != std::string::npos, std::format("donation email has {}", probe)); } Check(donationMail.find("BEGIN PGP SIGNED MESSAGE") == std::string::npos && donationMail.find("filename=\"catcrafts-invoice-") == std::string::npos, "donation email attaches no invoice"); } // The no-email donation stays unmailed: sit out two mailer sweeps and // expect no message carrying its link. std::this_thread::sleep_for(std::chrono::seconds(5)); bool mailedAnyway = false; 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; if (ReadFile(entry.path()).find(std::format("/order/{}", token)) != std::string::npos) { mailedAnyway = true; } } Check(!mailedAnyway, "a donation without an email is never emailed"); } // 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, const Product& pr) { const std::string shop = "/shop/" + pr.slug; // 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, "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. The wording is kRegulatoryMessage's, as a FIELD // error (the same sentence also rides in the preview blob, so the error // markup is what is matched; the apostrophe is escaped as the renderer // escapes it). { const auto refused = srv.Post(shop, "email=us%40example.org&name=Pat&street=1%20Main%20St&postal=43004&city=Columbus&country=US"); Check(refused.body.find("field__error\">Catcrafts can't ship there") != 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"); } // A sanctioned destination gets the sanctions sentence, not the policy // one — the buyer should learn the law forbids the sale, not wonder what // insurance has to do with Moscow. { const auto refused = srv.Post(shop, "email=ru%40example.org&name=Sasha&street=1%20Tverskaya&postal=125009&city=Moscow&country=RU"); Check(refused.body.find("EU sanctions prohibit") != std::string::npos, "sanctions refusal explains itself on the form"); Check(refused.body.find("field__error\">Catcrafts can't ship there") == std::string::npos, "sanctions refusal is not worded as the policy one"); } // The buy panel warns before anyone fills it in — positively, as the // allow-list is the whole policy — and the preview script carries the // same lists so it cannot quote a total the server would refuse: `w` is // the shipping allow-list (a country is refused by its ABSENCE), `s` the // sanctioned countries. No deny-list survives in the payload // (ShouldShipContent pins the exact contents against the fixture). srv.BodyHas(shop, "Catcrafts currently ships to the Netherlands", "buy panel states where the shop ships"); srv.BodyHas(shop, "cannot sell or ship to Russia, Belarus or North Korea", "buy panel states where the law forbids selling"); srv.BodyHas(shop, ""w":["NL",", "total preview carries the shipping allow-list"); srv.BodyLacks(shop, ""x":[", "no deny-list survives in the preview payload"); srv.BodyHas(shop, ""s":["RU","BY","KP"]", "total preview knows the sanctioned 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, 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(std::tolower(static_cast(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()); } } } // ── one rail down ───────────────────────────────────────────────────── // // The regression guard for a real outage: on 2026-08-20 the bank provider // closed the shop's account, the operator correctly dropped the credential, // and the form went on rendering a PRE-SELECTED "Bank or card" option that // checkout could then only answer with a 503 — the majority of buyers walking // into a wall. The crypto slot had always been rendered conditionally; the // bank slot never was, because until that day it had never been absent. // // Its own server, because a rail roster is fixed at startup. Donations are // what this asserts against: they are open in BOTH shop states, so this runs // whatever the goods' status is, or whether any are listed. void OneRailDown(const char* binary) { ServerOptions options; options.extraArgs = { "--rail=off", "--crypto-rail=fake-crypto" }; TestServer srv(binary, 8221, options); srv.BodyHas("/shop/donation", "name=\"pay\"", "with one rail down the form still names the choice it has"); srv.BodyHas("/shop/donation", "value=\"crypto\" checked", "the surviving rail is pre-selected, so a plain submit is payable"); srv.BodyLacks("/shop/donation", "value=\"bank\"", "the dead rail is not offered at all"); // The proof that the rendering and the handler agree: submitting the form // exactly as rendered — no pay field touched — must create an order rather // than be refused. An absent `pay` resolves to the BANK rail by design, so // this is what would fail if the fieldset ever stopped pre-selecting. const auto created = srv.Post("/shop/donation", "amount=5&pay=crypto"); Check(created.status == "303", "a donation on the surviving rail goes through"); } } // namespace int main(int argc, char** argv) { ServerOptions options; options.gpg = true; options.mailer = true; TestServer srv(argv[1], 8217, options); const Product* pr = FirstPricedProduct(); if (!pr) { std::println("note: the catalogue lists no priced product; the goods checkout, " "order-lifecycle and invoice checks did not run and re-arm when one " "is listed"); } else if (srv.ShopOpen(pr->slug)) { OpenShopLifecycle(srv, *pr); } else { ComingSoon(srv, *pr); } if (pr) AlwaysOnValidation(srv, *pr); // The donation item is open in both shop states — that is the point of it. DonationLifecycle(srv); if (pr && srv.ShopOpen(pr->slug)) { RejectedFormEcho(srv, *pr); } OneRailDown(argv[1]); return Finish(); }