payment tests
Some checks failed
Deploy / build-deploy (push) Failing after 4m58s

This commit is contained in:
Jorijn van der Graaf 2026-08-20 01:36:42 +02:00
commit 45992c4f91
10 changed files with 670 additions and 13 deletions

View 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();
}

View file

@ -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",

View 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();
}

View file

@ -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);