/* 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 a bank, 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 #include #include #include #include #include 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. // // CH carries the €55 world rate because it is the export destination the shop // actually sells to; DE and GB keep their rows even though checkout now refuses // both (unregistered producer schemes) precisely BECAUSE it refuses them — a // destination the carrier prices and the policy declines is the case worth // having a fixture for, and the suites assert that the policy wins. 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" R"("CH":[[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 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-.eml. bool mailer = false; // Extra environment for the server. std::vector> 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 : { "TRANSFER_IBAN", "EURC_CHAINS", "EURC_POOL", "SENDCLOUD_PUBLIC_KEY", "SENDCLOUD_SECRET_KEY", "SENDCLOUD_METHOD", "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 ' " "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 ", 1); } for (const auto& [name, value] : options.env) { ::setenv(name.c_str(), value.c_str(), 1); } std::vector 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); // A stale server from an aborted run squatting on this port would // answer the readiness probe while OUR child dies on a failed bind — // every request then hits the wrong server and the suite fails on // assertions that cannot make sense (an order that "vanishes" from // the ledger). The pdeathsig in Spawn stops the leak from happening // again; this stops anything already leaked (or anything else on the // port) from being mistaken for the server under test. try { (void)Get("/api/healthz"); std::println(std::cerr, "e2e: something is already listening on port {} — a stale " "catcrafts-server from an aborted run? Find it with " "`pgrep -af catcrafts-server`, kill it, and re-run.", port_); std::exit(1); } catch (...) { // Nothing answered: the port is ours to take. } 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 [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
. 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& argv) { log_ = work_ / "server.log"; std::vector cargv; cargv.reserve(argv.size() + 1); for (const std::string& a : argv) cargv.push_back(const_cast(a.c_str())); cargv.push_back(nullptr); pid_ = ::fork(); if (pid_ == 0) { // Die WITH the suite. A suite killed hard — Ctrl+C, a runner // timeout, a crash that skips destructors — must not leave this // child alive holding the port: the leaked server answers the // next run's probes and every assertion after that lies. ::prctl(PR_SET_PDEATHSIG, SIGKILL); if (::getppid() == 1) ::_exit(127); // parent died before prctl took 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