catcrafts.net/tests/ShouldCreateMollieTestPayments/main.cpp

194 lines
9 KiB
C++
Raw Normal View History

2026-08-20 01:36:42 +02:00
/*
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();
}