This commit is contained in:
parent
47d302a9a4
commit
45992c4f91
10 changed files with 670 additions and 13 deletions
194
tests/ShouldCreateMollieTestPayments/main.cpp
Normal file
194
tests/ShouldCreateMollieTestPayments/main.cpp
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
/*
|
||||
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 Mollie rail against the REAL api.mollie.com, on a test-mode key: a €1
|
||||
// donation whose payment is actually created at Mollie, polled by the real
|
||||
// reconciler, and read back by this suite with its own authenticated GET.
|
||||
// The fake-rail suites prove the lifecycle; the parser suite proves the
|
||||
// decoding; what neither can prove is the live conversation — the bearer
|
||||
// auth, the amount formatting Mollie accepts, the JSON shape they answer
|
||||
// with today. This suite is a MANDATORY deploy gate in CI (the operator's
|
||||
// call — caution over convenience); locally it skips unless
|
||||
// MOLLIE_TEST_API_KEY is exported, and in CI that same missing secret is a
|
||||
// FAILURE, never a quiet skip.
|
||||
//
|
||||
// Coverage stops at Pending, deliberately. Mollie has no API that marks a
|
||||
// test payment paid — the test-mode checkout page is where a human (or a
|
||||
// headless browser this repo does not carry) picks the outcome. So the paid
|
||||
// transition stays covered by the fake rail and by the pre-launch manual
|
||||
// click-through; what this suite pins is everything up to it: create, the
|
||||
// checkout URL, the ledger record, the poll reading "open" as still-awaiting
|
||||
// rather than as dead, and no errors on the wire. Test-mode payments expire
|
||||
// at Mollie on their own; nothing is left behind.
|
||||
//
|
||||
// The key must be a test_ key. A live_ key is refused outright, in every
|
||||
// environment: this suite creates payments, and a payment created on the
|
||||
// live key is a real invoice in the shop's Mollie dashboard.
|
||||
|
||||
import std;
|
||||
import Crafter.Network;
|
||||
import Catcrafts.E2eHarness;
|
||||
|
||||
using namespace Catcrafts::E2e;
|
||||
|
||||
namespace {
|
||||
|
||||
bool IsCi() {
|
||||
for (const char* v : { "CI", "GITHUB_ACTIONS", "FORGEJO_ACTIONS" }) {
|
||||
if (const char* s = std::getenv(v); s && *s) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
std::string FirstMatch(const std::string& text, const std::string& pattern) {
|
||||
std::smatch m;
|
||||
if (std::regex_search(text, m, std::regex(pattern))) return m[1].str();
|
||||
return {};
|
||||
}
|
||||
|
||||
// One authenticated GET against the live API, the suite's own eyes on the
|
||||
// payment the server created — same endpoint the reconciler polls, but read
|
||||
// here independently so a server-side bug cannot vouch for itself.
|
||||
std::string MollieGet(const std::string& key, const std::string& path) {
|
||||
try {
|
||||
Crafter::ClientHTTP1 client("api.mollie.com", 443,
|
||||
Crafter::TLSClientCredentials{});
|
||||
Crafter::HTTPRequest req;
|
||||
req.method = "GET";
|
||||
req.path = path;
|
||||
req.authority = "api.mollie.com";
|
||||
req.headers["authorization"] = "Bearer " + key;
|
||||
req.headers["user-agent"] = "catcrafts.net-e2e/1.0 (+https://catcrafts.net)";
|
||||
const Crafter::HTTPResponse res = client.Send(req);
|
||||
if (res.status.size() != 3 || res.status[0] != '2') {
|
||||
std::println(std::cerr, "mollie e2e: GET {} -> {} {}", path, res.status,
|
||||
res.body.substr(0, 200));
|
||||
return {};
|
||||
}
|
||||
return res.body;
|
||||
} catch (const std::exception& e) {
|
||||
std::println(std::cerr, "mollie e2e: GET {} failed: {}", path, e.what());
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
if (argc < 2) {
|
||||
std::println(std::cerr, "usage: ShouldCreateMollieTestPayments <server-binary>");
|
||||
return 2;
|
||||
}
|
||||
|
||||
const char* keyEnv = std::getenv("MOLLIE_TEST_API_KEY");
|
||||
if (!keyEnv || !*keyEnv) {
|
||||
if (IsCi()) {
|
||||
std::println(std::cerr,
|
||||
"FAIL: MOLLIE_TEST_API_KEY is not set. This suite is a mandatory "
|
||||
"deploy gate: add the secret in Forgejo (repo Settings -> Actions -> "
|
||||
"Secrets) — the test_ key from the Mollie dashboard, Developers -> "
|
||||
"API keys. See deploy/README.md \"Live payment suites in CI\".");
|
||||
return 1;
|
||||
}
|
||||
std::println("ShouldCreateMollieTestPayments: skipped — MOLLIE_TEST_API_KEY is "
|
||||
"not set (mandatory in CI, opt-in locally)");
|
||||
return 0;
|
||||
}
|
||||
const std::string key(keyEnv);
|
||||
if (!key.starts_with("test_")) {
|
||||
std::println(std::cerr,
|
||||
"FAIL: MOLLIE_TEST_API_KEY does not start with test_ — refusing to run "
|
||||
"a payment-creating suite on anything but a test-mode key.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
ServerOptions options;
|
||||
options.extraArgs = { "--rail=mollie", "--crypto-rail=fake-crypto" };
|
||||
options.env = { { "MOLLIE_API_KEY", key } };
|
||||
TestServer srv(argv[1], 8219, options);
|
||||
|
||||
// ── checkout: a €1 donation on the bank rail ─────────────────────
|
||||
// The 303 goes to Mollie's hosted checkout, not the order page — that IS
|
||||
// the assertion: a real payment now exists and has somewhere to be paid.
|
||||
const auto created = srv.Post("/shop/donation", "amount=1");
|
||||
Check(created.status == "303", "a €1 donation 303s to the payment",
|
||||
created.status);
|
||||
std::string checkoutUrl;
|
||||
if (const auto it = created.headers.find("location"); it != created.headers.end()) {
|
||||
checkoutUrl = it->second;
|
||||
}
|
||||
Check(checkoutUrl.starts_with("https://")
|
||||
&& checkoutUrl.find("mollie.com") != std::string::npos,
|
||||
"the redirect is Mollie's hosted checkout", checkoutUrl);
|
||||
if (created.status != "303" || checkoutUrl.empty()) {
|
||||
std::println(std::cerr, "server log:\n{}",
|
||||
ReadFile(srv.Work() / "server.log"));
|
||||
return Finish();
|
||||
}
|
||||
|
||||
// ── the ledger records the live payment ──────────────────────────
|
||||
const std::string ledger = srv.OrdersText();
|
||||
const std::string token = FirstMatch(ledger, R"lit("type":"order".*?"id":"([0-9a-f]{32})")lit");
|
||||
const std::string payId = FirstMatch(ledger, R"lit("pay_id":"(tr_[A-Za-z0-9]+)")lit");
|
||||
Check(!token.empty(), "the order reached the ledger");
|
||||
Check(!payId.empty(), "the ledger carries Mollie's tr_ payment id");
|
||||
Check(ledger.find("\"pay_choice\":\"bank\"") != std::string::npos,
|
||||
"a donation with no pay field lands on the bank rail");
|
||||
Check(ledger.find("\"total_minor\":100") != std::string::npos,
|
||||
"€1 is stored as 100 cents");
|
||||
if (token.empty() || payId.empty()) return Finish();
|
||||
const std::string orderPath = std::format("/order/{}", token);
|
||||
|
||||
// ── the suite's own read of the payment at Mollie ─────────────────
|
||||
// status open (fresh, method not yet chosen), the exact amount format
|
||||
// Mollie accepted, and OUR redirect back to this order — the round trip
|
||||
// that proves FormatMinor and the create body against the live API.
|
||||
{
|
||||
const std::string payment = MollieGet(key, "/v2/payments/" + payId);
|
||||
Check(!payment.empty(), "the payment the server created exists at Mollie");
|
||||
if (!payment.empty()) {
|
||||
Check(payment.find("\"status\":\"open\"") != std::string::npos,
|
||||
"a fresh test payment reads as open",
|
||||
FirstMatch(payment, R"lit("status":"([a-z]+)")lit"));
|
||||
Check(payment.find("\"currency\":\"EUR\"") != std::string::npos
|
||||
&& payment.find("\"value\":\"1.00\"") != std::string::npos,
|
||||
"the amount arrived as EUR 1.00");
|
||||
Check(payment.find("/order/" + token) != std::string::npos,
|
||||
"the payment's redirectUrl returns to this order");
|
||||
}
|
||||
}
|
||||
|
||||
// ── the real poll reads open as still-awaiting ────────────────────
|
||||
// Rendering the page triggers the arrival poll and the reconciler polls
|
||||
// on Mollie's 10 s cadence; give both time for at least two live GETs.
|
||||
// "Open" must stay awaiting — parsed as Dead it would cancel the order,
|
||||
// parsed as an error it would log below.
|
||||
{
|
||||
const std::string page = srv.Body(orderPath);
|
||||
Check(page.find("awaiting payment") != std::string::npos,
|
||||
"the fresh order page shows awaiting payment");
|
||||
Check(page.find("Resume payment") != std::string::npos,
|
||||
"the order page offers the resume link");
|
||||
Check(page.find("mollie.com") != std::string::npos,
|
||||
"the resume link points at the hosted checkout");
|
||||
}
|
||||
std::this_thread::sleep_for(std::chrono::seconds(12));
|
||||
Check(srv.Body(orderPath).find("awaiting payment") != std::string::npos,
|
||||
"an open payment is still awaiting after live reconciler polls");
|
||||
{
|
||||
// Any "mollie:" line is a failed call — auth, transport, or a JSON
|
||||
// shape the parser refused. Create succeeded (the checkout URL above),
|
||||
// so a clean log here means the polls succeeded too.
|
||||
const std::string log = ReadFile(srv.Work() / "server.log");
|
||||
Check(log.find("mollie:") == std::string::npos,
|
||||
"no Mollie call failed during create or polling",
|
||||
FirstMatch(log, R"((mollie:[^\n]*))"));
|
||||
}
|
||||
|
||||
return Finish();
|
||||
}
|
||||
|
|
@ -65,6 +65,27 @@ int main() {
|
|||
"eurc: numeric result rejected — the wire type is a hex string");
|
||||
Check(!ParseEthCallUint("garbage").has_value(), "eurc: malformed payload");
|
||||
|
||||
// The OUTBOUND half of the balance check, byte for byte: selector, then
|
||||
// the address left-padded into exactly one 32-byte ABI word — 24 zero hex
|
||||
// digits (12 bytes), then the 40 address digits. Pinned as a literal
|
||||
// because this line once padded 24 BYTES instead of 12: the address slid
|
||||
// past the argument word, every node cleanly answered balanceOf of a
|
||||
// zero-balance garbage address, and paid orders read as unpaid forever —
|
||||
// no error on either side. A wrong length here is money-losing even when
|
||||
// every reply parses.
|
||||
{
|
||||
const std::string data = Server::BalanceOfCallData(
|
||||
"0x311660cfd1d0c35616cf6dfc3932881b22bb46ec");
|
||||
Check(data ==
|
||||
"0x70a08231"
|
||||
"000000000000000000000000"
|
||||
"311660cfd1d0c35616cf6dfc3932881b22bb46ec",
|
||||
"eurc: balanceOf calldata is selector + one 32-byte word", data);
|
||||
Check(data.size() == 2 + 8 + 64,
|
||||
"eurc: balanceOf calldata is exactly 4 + 32 bytes",
|
||||
std::to_string(data.size()));
|
||||
}
|
||||
|
||||
const auto chains = Server::ParseEurcChains(R"({"chains":[
|
||||
{"name":"base","rpc":"https://mainnet.base.org",
|
||||
"contract":"0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42",
|
||||
|
|
|
|||
282
tests/ShouldSettleEurcOnTestnet/main.cpp
Normal file
282
tests/ShouldSettleEurcOnTestnet/main.cpp
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
/*
|
||||
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 EURC rail against a REAL chain: a €1 donation paid in real (testnet)
|
||||
// EURC on Ethereum Sepolia, settled by the same balanceOf quorum the mainnet
|
||||
// rail runs. The fake-rail suites prove the order lifecycle; the unit suites prove
|
||||
// the decoding; what neither can prove is that the rail's actual RPC
|
||||
// conversation — eth_chainId verification, the ABI-encoded eth_call, two
|
||||
// endpoints corroborating a covering balance — works against nodes we do not
|
||||
// control. A mistake there costs real money on mainnet, which is why this
|
||||
// suite exists and why it is a MANDATORY deploy gate in CI, network flake and
|
||||
// all: a deploy that cannot prove the crypto rail settles is not a deploy
|
||||
// (the operator's call — caution over convenience).
|
||||
//
|
||||
// Locally it skips unless EURC_E2E_PRIVATE_KEY is exported, so a train ride
|
||||
// still runs the rest of the suites. In CI the same missing key is a FAILURE:
|
||||
// a vanished secret must never quietly turn the gate into a pass.
|
||||
//
|
||||
// What it needs (see deploy/README.md "Live payment suites in CI"):
|
||||
// EURC_E2E_PRIVATE_KEY an Ethereum Sepolia key holding testnet EURC
|
||||
// (Circle's faucet, network set to Ethereum Sepolia)
|
||||
// and some Sepolia ETH for gas (the pk910 PoW faucet
|
||||
// mines it, no account). Each run spends 1 EURC + gas.
|
||||
// cast Foundry's CLI, the only tool in a stock shell that
|
||||
// can sign an ERC-20 transfer. The server itself
|
||||
// deliberately cannot — that is the rail's design.
|
||||
//
|
||||
// The receiving pool is generated FRESH here, random addresses nobody holds a
|
||||
// key for. That is load-bearing, not laziness: CheckPaid compares the
|
||||
// address's TOTAL balance, so a reused address still holding last run's
|
||||
// 1 EURC would settle this run's order before any payment — a false pass.
|
||||
// The 1 EURC sent each run is stranded at the random address, which on a
|
||||
// testnet costs nothing.
|
||||
//
|
||||
// block_tag is "latest" rather than production's "finalized": Sepolia-family
|
||||
// finality is ~13 minutes, and a reorg un-paying a testnet donation is not a
|
||||
// risk worth a quarter-hour CI stall. The rail warns about it at load; that
|
||||
// warning appearing in this suite's server log is expected.
|
||||
|
||||
import std;
|
||||
import Crafter.Network;
|
||||
import Catcrafts.E2eHarness;
|
||||
|
||||
using namespace Catcrafts::E2e;
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
namespace {
|
||||
|
||||
// Circle's EURC on Ethereum Sepolia. Verify only against
|
||||
// https://developers.circle.com/stablecoins/eurc-contract-addresses
|
||||
constexpr std::string_view kContract = "0x08210F9170F89Ab7658F0B5E3fF39b0E03C594D4";
|
||||
constexpr std::string_view kChainId = "11155111";
|
||||
// Two endpoints from different operators (Allnodes, 1RPC) — the same two
|
||||
// operators the production ethereum entry uses — because the rule under test
|
||||
// is "no single node's word settles an order": min_confirmations=2 below
|
||||
// makes both agree, same as the mainnet chains file.
|
||||
constexpr std::string_view kRpcPrimary = "https://ethereum-sepolia-rpc.publicnode.com";
|
||||
constexpr std::string_view kRpcSecondary = "https://1rpc.io/sepolia";
|
||||
|
||||
// €1 donation = 100 cents = 100 × 10^(6-2) EURC base units.
|
||||
constexpr std::string_view kTransferUnits = "1000000";
|
||||
|
||||
bool IsCi() {
|
||||
for (const char* v : { "CI", "GITHUB_ACTIONS", "FORGEJO_ACTIONS" }) {
|
||||
if (const char* s = std::getenv(v); s && *s) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// The order token from a checkout redirect's Location header — the EURC rail
|
||||
// has no hosted page, so the 303 lands back on the order page itself.
|
||||
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 {};
|
||||
}
|
||||
|
||||
std::string RandomAddress(std::mt19937_64& rng) {
|
||||
static constexpr char hex[] = "0123456789abcdef";
|
||||
std::string s = "0x";
|
||||
for (int i = 0; i < 40; ++i) s += hex[rng() & 0xf];
|
||||
return s;
|
||||
}
|
||||
|
||||
std::string Trimmed(std::string s) {
|
||||
while (!s.empty() && (s.back() == '\n' || s.back() == '\r' || s.back() == ' ')) {
|
||||
s.pop_back();
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
if (argc < 2) {
|
||||
std::println(std::cerr, "usage: ShouldSettleEurcOnTestnet <server-binary>");
|
||||
return 2;
|
||||
}
|
||||
|
||||
const char* senderKey = std::getenv("EURC_E2E_PRIVATE_KEY");
|
||||
if (!senderKey || !*senderKey) {
|
||||
if (IsCi()) {
|
||||
std::println(std::cerr,
|
||||
"FAIL: EURC_E2E_PRIVATE_KEY is not set. This suite is a mandatory "
|
||||
"deploy gate: add the secret in Forgejo (repo Settings -> Actions -> "
|
||||
"Secrets) — an Ethereum Sepolia key funded with testnet EURC and gas. "
|
||||
"See deploy/README.md \"Live payment suites in CI\".");
|
||||
return 1;
|
||||
}
|
||||
std::println("ShouldSettleEurcOnTestnet: skipped — EURC_E2E_PRIVATE_KEY is not "
|
||||
"set (mandatory in CI, opt-in locally)");
|
||||
return 0;
|
||||
}
|
||||
// The key never enters a command line this process builds: cast reads it
|
||||
// from the environment via shell expansion, so it cannot leak through a
|
||||
// logged command or the failure output below.
|
||||
if (std::system("command -v cast >/dev/null 2>&1") != 0) {
|
||||
std::println(std::cerr,
|
||||
"FAIL: EURC_E2E_PRIVATE_KEY is set but `cast` (Foundry) is not on PATH — "
|
||||
"the suite cannot sign the testnet transfer without it. CI installs it in "
|
||||
"the deploy workflow; locally: https://getfoundry.sh");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// The suite's own scratch dir: the chains file and pool must exist before
|
||||
// the server spawns, so they cannot live in TestServer's work dir.
|
||||
std::random_device rd;
|
||||
std::mt19937_64 rng((static_cast<std::uint64_t>(rd()) << 32) ^ rd());
|
||||
const fs::path dir = fs::temp_directory_path()
|
||||
/ std::format("catcrafts-eurc-testnet-{:016x}", rng());
|
||||
std::error_code ec;
|
||||
fs::remove_all(dir, ec);
|
||||
fs::create_directories(dir);
|
||||
|
||||
const fs::path chains = dir / "chains.json";
|
||||
WriteFile(chains, std::format(
|
||||
R"({{"chains":[{{"name":"ethereum-sepolia",)"
|
||||
R"("rpcs":["{}","{}"],)"
|
||||
R"("min_confirmations":2,)"
|
||||
R"("contract":"{}","chain_id":{},)"
|
||||
R"("block_tag":"latest","note":"testnet"}}]}})",
|
||||
kRpcPrimary, kRpcSecondary, kContract, kChainId));
|
||||
|
||||
// Fresh random addresses — see the header for why reuse would false-pass.
|
||||
const fs::path pool = dir / "pool.txt";
|
||||
{
|
||||
std::string lines;
|
||||
for (int i = 0; i < 3; ++i) lines += RandomAddress(rng) + "\n";
|
||||
WriteFile(pool, lines);
|
||||
}
|
||||
|
||||
ServerOptions options;
|
||||
// Bank slot on the fake rail so the donation form offers the choice; the
|
||||
// crypto slot is the real EURC rail pointed at the testnet chains file.
|
||||
options.extraArgs = { "--rail=fake", "--crypto-rail=eurc" };
|
||||
options.env = { { "EURC_CHAINS", chains.string() },
|
||||
{ "EURC_POOL", pool.string() } };
|
||||
TestServer srv(argv[1], 8218, options);
|
||||
|
||||
// ── checkout: a €1 crypto donation ────────────────────────────────
|
||||
const auto created = srv.Post("/shop/donation", "amount=1&pay=crypto");
|
||||
const std::string token = TokenOf(created);
|
||||
Check(created.status == "303" && !token.empty(),
|
||||
"a €1 crypto donation 303s to its order page", created.status);
|
||||
if (token.empty()) {
|
||||
std::println(std::cerr, "server log:\n{}",
|
||||
ReadFile(srv.Work() / "server.log"));
|
||||
fs::remove_all(dir, ec);
|
||||
return Finish();
|
||||
}
|
||||
const std::string orderPath = std::format("/order/{}", token);
|
||||
|
||||
// The issued address, from the ledger — the same place the reconciler
|
||||
// reads it, so this is the address the rail is actually watching.
|
||||
std::string address;
|
||||
{
|
||||
const std::string ledger = srv.OrdersText();
|
||||
std::smatch m;
|
||||
if (std::regex_search(ledger, m, std::regex(
|
||||
R"lit("id":")lit" + token
|
||||
+ R"lit(".*?"pay_choice":"crypto".*?"pay_id":"(0x[0-9a-f]{40})@[0-9]+")lit"))) {
|
||||
address = m[1].str();
|
||||
}
|
||||
Check(!address.empty(),
|
||||
"the ledger records a crypto order with an address@deadline pay id");
|
||||
}
|
||||
if (address.empty()) {
|
||||
std::println(std::cerr, "ledger was:\n{}\nserver log:\n{}",
|
||||
srv.OrdersText(), ReadFile(srv.Work() / "server.log"));
|
||||
fs::remove_all(dir, ec);
|
||||
return Finish();
|
||||
}
|
||||
std::println("issued receiving address: {}", address);
|
||||
|
||||
// ── the order page asks for exactly the right payment ────────────
|
||||
{
|
||||
const std::string page = srv.Body(orderPath);
|
||||
Check(page.find("Pay with EURC") != std::string::npos,
|
||||
"the order page renders the self-hosted payment instructions");
|
||||
Check(page.find(address) != std::string::npos,
|
||||
"the order page shows the issued address");
|
||||
// The EIP-681 wallet link carries the amount in base units — the
|
||||
// number a wrong scale would corrupt 10,000× in either direction.
|
||||
Check(page.find(std::format("uint256={}", kTransferUnits)) != std::string::npos,
|
||||
"the wallet link asks for €1 in EURC base units");
|
||||
Check(page.find("ethereum-sepolia") != std::string::npos
|
||||
|| page.find("Ethereum-sepolia") != std::string::npos,
|
||||
"the order page names the watched chain");
|
||||
}
|
||||
|
||||
// ── negative control: an unpaid order must not settle ────────────
|
||||
// The reconciler has polled the real RPCs at least once by now (first
|
||||
// sweep lands ~1 s after the order); a zero balance must read as Pending.
|
||||
std::this_thread::sleep_for(std::chrono::seconds(3));
|
||||
Check(srv.Body(orderPath).find("awaiting payment") != std::string::npos,
|
||||
"an unpaid order stays awaiting after a real RPC poll");
|
||||
|
||||
// ── the customer pays: 1 real EURC on Ethereum Sepolia ────────────
|
||||
{
|
||||
const fs::path who = dir / "sender.txt";
|
||||
std::system(std::format(
|
||||
"cast wallet address --private-key \"$EURC_E2E_PRIVATE_KEY\" > \"{}\" 2>&1",
|
||||
who.string()).c_str());
|
||||
std::println("paying from: {}", Trimmed(ReadFile(who)));
|
||||
}
|
||||
const fs::path sendLog = dir / "cast-send.log";
|
||||
const int sent = std::system(std::format(
|
||||
"cast send {} \"transfer(address,uint256)\" {} {} "
|
||||
"--rpc-url {} --private-key \"$EURC_E2E_PRIVATE_KEY\" > \"{}\" 2>&1",
|
||||
kContract, address, kTransferUnits, kRpcPrimary, sendLog.string()).c_str());
|
||||
Check(sent == 0, "cast send transfers 1 testnet EURC and gets a receipt");
|
||||
if (sent != 0) {
|
||||
std::println(std::cerr,
|
||||
"cast send failed — if it reports insufficient funds, top the sender "
|
||||
"up: EURC at https://faucet.circle.com (network: Ethereum Sepolia), "
|
||||
"gas ETH at https://sepolia-faucet.pk910.de. cast said:\n{}",
|
||||
ReadFile(sendLog));
|
||||
fs::remove_all(dir, ec);
|
||||
return Finish();
|
||||
}
|
||||
std::println("transfer confirmed on-chain; waiting for the rail to notice");
|
||||
|
||||
// ── the rail notices, both endpoints agreeing ─────────────────────
|
||||
// Cadence: the reconciler polls this order every 30 s (the rail's own
|
||||
// interval), and both endpoints must see the balance at "latest". 150 s
|
||||
// is five polls past the ~12 s block that included the transfer — roomy,
|
||||
// tight enough to fail a deploy that would leave real buyers unconfirmed.
|
||||
{
|
||||
const std::string page = srv.WaitForBody(orderPath, "Thank you", 600);
|
||||
Check(page.find("Thank you") != std::string::npos,
|
||||
"the paid donation page appears once the balance covers the order");
|
||||
Check(page.find("Pay with EURC") == std::string::npos,
|
||||
"a settled order stops asking for money");
|
||||
}
|
||||
{
|
||||
const std::string ledger = srv.OrdersText();
|
||||
Check(ledger.find("\"status\":\"paid\"") != std::string::npos,
|
||||
"the paid transition reached the ledger");
|
||||
// The via column must name the chain that settled it — the fact the
|
||||
// bookkeeping keeps about where the money lives.
|
||||
Check(ledger.find("\"via\":\"eurc-ethereum-sepolia\"") != std::string::npos,
|
||||
"the paid event records eurc-ethereum-sepolia as the method");
|
||||
Check(ledger.find("\"type\":\"invoice\"") == std::string::npos,
|
||||
"a donation settles without an invoice");
|
||||
}
|
||||
if (failures != 0) {
|
||||
std::println(std::cerr, "server log:\n{}",
|
||||
ReadFile(srv.Work() / "server.log"));
|
||||
}
|
||||
|
||||
fs::remove_all(dir, ec);
|
||||
return Finish();
|
||||
}
|
||||
|
|
@ -22,6 +22,7 @@ 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;
|
||||
|
|
@ -187,6 +188,24 @@ public:
|
|||
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();
|
||||
}
|
||||
|
|
@ -297,6 +316,12 @@ private:
|
|||
|
||||
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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue