This commit is contained in:
parent
33c68c2f44
commit
749f525f83
44 changed files with 5380 additions and 3532 deletions
338
tests/harness/Catcrafts.E2eHarness.cppm
Normal file
338
tests/harness/Catcrafts.E2eHarness.cppm
Normal 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
|
||||
Loading…
Reference in a new issue