All checks were successful
Deploy / build-deploy (push) Successful in 2m23s
346 lines
14 KiB
C++
346 lines
14 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 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.
|
|
//
|
|
// 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 : { "MOLLIE_API_KEY", "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);
|
|
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
|