catcrafts.net/tests/harness/Catcrafts.E2eHarness.cppm
Jorijn van der Graaf df91762271
All checks were successful
Deploy / build-deploy (push) Successful in 3m47s
Replaced mollie
2026-08-20 20:15:47 +02:00

371 lines
15 KiB
C++

/*
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 <fcntl.h>
#include <signal.h>
#include <stdlib.h>
#include <sys/prctl.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.
//
// 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<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.
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 : { "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 <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);
// 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 <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) {
// 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