2026-08-15 00:54:05 +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 whole order lifecycle against the real binary: checkout, the payment
|
|
|
|
|
|
// choice, export pricing, the refusals, the fake rail settling, the signed
|
|
|
|
|
|
// invoice, the confirmation email, and the financials page agreeing with the
|
|
|
|
|
|
// ledger. The fake payment rail makes this testable: it hands out pretend
|
|
|
|
|
|
// payment links and reports "paid" once the marker file exists — which is how
|
|
|
|
|
|
// this suite simulates the customer paying.
|
|
|
|
|
|
//
|
|
|
|
|
|
// The lifecycle half runs only while the shop is OPEN; while coming-soon it
|
|
|
|
|
|
// asserts the closed behaviour instead. Launch day (status flip to
|
|
|
|
|
|
// "available" in Catcrafts.Shared-Content.cppm) re-arms the full suite with
|
|
|
|
|
|
// no edit here. The validation half runs in both states on purpose:
|
|
|
|
|
|
// validation happens before the coming-soon check, and that ordering is
|
|
|
|
|
|
// exactly what it pins.
|
|
|
|
|
|
|
|
|
|
|
|
import std;
|
|
|
|
|
|
import Catcrafts.Shared;
|
|
|
|
|
|
import Crafter.Network;
|
|
|
|
|
|
import Catcrafts.E2eHarness;
|
|
|
|
|
|
|
|
|
|
|
|
using namespace Catcrafts;
|
|
|
|
|
|
using namespace Catcrafts::E2e;
|
|
|
|
|
|
|
|
|
|
|
|
namespace {
|
|
|
|
|
|
|
|
|
|
|
|
constexpr std::string_view kGood =
|
|
|
|
|
|
"email=e2e%40example.org&name=Ada%20Lovelace&street=Main%20St%201&postal=1234AB&city=Delft&country=nl";
|
|
|
|
|
|
|
|
|
|
|
|
std::string Good(std::string_view suffix = {}) {
|
|
|
|
|
|
return std::string(kGood) + std::string(suffix);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// The order token from a checkout redirect's Location header.
|
|
|
|
|
|
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 {};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// The ledger is JSONL — one event per line.
|
|
|
|
|
|
std::vector<std::string> LedgerLines(TestServer& srv) {
|
|
|
|
|
|
std::vector<std::string> lines;
|
|
|
|
|
|
for (auto part : std::views::split(srv.OrdersText(), '\n')) {
|
|
|
|
|
|
std::string_view line(part.begin(), part.end());
|
|
|
|
|
|
if (!line.empty()) lines.emplace_back(line);
|
|
|
|
|
|
}
|
|
|
|
|
|
return lines;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
std::size_t MailCount(TestServer& srv) {
|
|
|
|
|
|
std::size_t n = 0;
|
|
|
|
|
|
for (const auto& entry : std::filesystem::directory_iterator(srv.Work())) {
|
|
|
|
|
|
const std::string name = entry.path().filename().string();
|
|
|
|
|
|
if (name.starts_with("mail-") && name.ends_with(".eml")) ++n;
|
|
|
|
|
|
}
|
|
|
|
|
|
return n;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Wait until `predicate` holds, on the reconciler/mailer cadence.
|
|
|
|
|
|
bool SettleUntil(std::function<bool()> predicate, std::int32_t tries = 40) {
|
|
|
|
|
|
for (std::int32_t i = 0; i < tries; ++i) {
|
|
|
|
|
|
if (predicate()) return true;
|
|
|
|
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(250));
|
|
|
|
|
|
}
|
|
|
|
|
|
return predicate();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
bool GpgVerifies(const std::filesystem::path& file) {
|
|
|
|
|
|
return std::system(std::format("gpg --verify {} >/dev/null 2>&1",
|
|
|
|
|
|
file.string()).c_str()) == 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void OpenShopLifecycle(TestServer& srv) {
|
|
|
|
|
|
// ── checkout ──────────────────────────────────────────────────────
|
|
|
|
|
|
// A valid submission answers 303 straight to the PAYMENT page — no
|
|
|
|
|
|
// interim stop. The fake rail's payUrl is the order page itself, so the
|
|
|
|
|
|
// token is still extractable from the Location and the browser flow works
|
|
|
|
|
|
// in dev.
|
|
|
|
|
|
const auto checkout = srv.Post("/shop/fp6-pmos", Good());
|
|
|
|
|
|
const std::string token = TokenOf(checkout);
|
|
|
|
|
|
Check(checkout.status == "303" && !token.empty(),
|
|
|
|
|
|
"POST checkout -> 303 straight to payment", checkout.status);
|
|
|
|
|
|
{
|
|
|
|
|
|
const std::string ledger = srv.OrdersText();
|
|
|
|
|
|
Check(ledger.find("\"country\":\"NL\"") != std::string::npos
|
2026-08-17 11:04:03 +02:00
|
|
|
|
&& ledger.find("\"total_minor\":58880") != std::string::npos,
|
|
|
|
|
|
"order stored: NL total is €588.80 (green €573.80 + €15 shipping)");
|
2026-08-15 00:54:05 +02:00
|
|
|
|
// No `pay` field in that submission, which is what a form with only
|
|
|
|
|
|
// one rail configured posts: it must land on the bank rail rather
|
|
|
|
|
|
// than nothing.
|
|
|
|
|
|
Check(ledger.find("\"pay_choice\":\"bank\"") != std::string::npos,
|
|
|
|
|
|
"a submission with no payment choice records bank");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// The order page: awaiting payment, pay link, reference, self-refreshing,
|
|
|
|
|
|
// never indexed, never cached.
|
|
|
|
|
|
const std::string orderPath = std::format("/order/{}", token);
|
|
|
|
|
|
{
|
|
|
|
|
|
const std::string page = srv.Body(orderPath);
|
|
|
|
|
|
for (std::string_view probe : { "awaiting payment", "Resume payment", "CC-",
|
2026-08-17 11:04:03 +02:00
|
|
|
|
"http-equiv=\"refresh\"", "€588.80" }) {
|
2026-08-15 00:54:05 +02:00
|
|
|
|
Check(page.find(probe) != std::string::npos,
|
|
|
|
|
|
std::format("order page has {}", probe));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
srv.HeaderHas(orderPath, "x-robots-tag", "noindex", "order page is noindex");
|
|
|
|
|
|
srv.HeaderHas(orderPath, "cache-control", "no-store", "order page is never cached");
|
|
|
|
|
|
|
|
|
|
|
|
// Unknown and malformed tokens are the same 404.
|
|
|
|
|
|
srv.CheckStatus("/order/00000000000000000000000000000000", "404");
|
|
|
|
|
|
srv.CheckStatus("/order/not-a-token", "404");
|
|
|
|
|
|
srv.CheckStatus("/order/deadbeef", "404");
|
|
|
|
|
|
|
|
|
|
|
|
// ── the payment choice ────────────────────────────────────────────
|
|
|
|
|
|
// Both slots are configured here, so the form must offer both and the
|
|
|
|
|
|
// picked one must survive all the way into the ledger. The ledger is the
|
|
|
|
|
|
// assertion that matters: it is what the reconciler later reads to decide
|
|
|
|
|
|
// WHICH provider may confirm the order, so a choice that renders but is
|
|
|
|
|
|
// not stored would mean crypto orders being asked about at Mollie.
|
|
|
|
|
|
srv.BodyHas("/shop/fp6-pmos", "name=\"pay\"", "the form offers a payment choice");
|
|
|
|
|
|
srv.BodyHas("/shop/fp6-pmos", "value=\"crypto\"", "crypto is one of the choices");
|
|
|
|
|
|
srv.BodyHas("/shop/fp6-pmos", "value=\"bank\" checked", "bank is the pre-selected choice");
|
|
|
|
|
|
|
|
|
|
|
|
const std::string tokenCrypto = TokenOf(srv.Post("/shop/fp6-pmos", Good("&pay=crypto")));
|
|
|
|
|
|
Check(!tokenCrypto.empty(), "a crypto order goes through");
|
|
|
|
|
|
if (!tokenCrypto.empty()) {
|
|
|
|
|
|
bool recorded = false;
|
|
|
|
|
|
for (const std::string& line : LedgerLines(srv)) {
|
|
|
|
|
|
if (line.find(std::format("\"id\":\"{}\"", tokenCrypto)) != std::string::npos) {
|
|
|
|
|
|
recorded = recorded
|
|
|
|
|
|
|| line.find("\"pay_choice\":\"crypto\"") != std::string::npos;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
Check(recorded, "the crypto choice is what the ledger records");
|
|
|
|
|
|
// The order page has to promise what is actually behind the button —
|
|
|
|
|
|
// the bank copy on a crypto order would send someone looking for
|
|
|
|
|
|
// iDEAL. (The shell suite pinned 'Lightning' here, CoinGate-era copy
|
|
|
|
|
|
// that had already left the codebase — this is the check that caught
|
|
|
|
|
|
// the drift when the port first ran against an open shop.)
|
|
|
|
|
|
const std::string page = srv.Body(std::format("/order/{}", tokenCrypto));
|
|
|
|
|
|
Check(page.find("completes your crypto payment") != std::string::npos,
|
|
|
|
|
|
"the crypto order page describes the crypto payment");
|
|
|
|
|
|
Check(page.find("iDEAL") == std::string::npos,
|
|
|
|
|
|
"the crypto order page does not promise iDEAL");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// A payment method nobody offers is refused, and refused as a FIELD error
|
|
|
|
|
|
// so the form comes back with the choice highlighted rather than a bare
|
|
|
|
|
|
// 400.
|
|
|
|
|
|
{
|
|
|
|
|
|
const auto bogus = srv.Post("/shop/fp6-pmos", Good("&pay=invoice-me-later"));
|
|
|
|
|
|
Check(bogus.status == "422", "an unknown payment method is refused", bogus.status);
|
|
|
|
|
|
Check(bogus.body.find("Pick one of the payment methods") != std::string::npos,
|
|
|
|
|
|
"the refusal names the payment field");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// A non-EU order: ex-VAT goods, world shipping, and the indicative
|
2026-08-18 11:12:24 +02:00
|
|
|
|
// national currency line sourced from the build-time ECB rates. CH because
|
|
|
|
|
|
// it is the export destination that actually sells — North America is
|
|
|
|
|
|
// refused on insurance, and GB waits on its e-waste registrations.
|
|
|
|
|
|
const std::string tokenCh = TokenOf(srv.Post("/shop/fp6-pmos",
|
|
|
|
|
|
"email=ch%40example.org&name=Heidi&street=1%20Bahnhofstrasse&postal=8001&city=Zurich&country=CH"));
|
|
|
|
|
|
Check(!tokenCh.empty(), "CH checkout issues an order");
|
|
|
|
|
|
if (!tokenCh.empty()) {
|
|
|
|
|
|
const std::string page = srv.Body(std::format("/order/{}", tokenCh));
|
2026-08-17 11:04:03 +02:00
|
|
|
|
// €474.21 goods (green net) + €55 world shipping = €529.21
|
|
|
|
|
|
Check(page.find("€529.21") != std::string::npos,
|
2026-08-15 00:54:05 +02:00
|
|
|
|
"export order total is ex-VAT + world shipping");
|
|
|
|
|
|
Check(page.find("Zero-rated export") != std::string::npos,
|
|
|
|
|
|
"export order states the VAT treatment");
|
2026-08-18 11:12:24 +02:00
|
|
|
|
Check(std::regex_search(page, std::regex("≈ CHF [0-9]+")),
|
|
|
|
|
|
"export order shows the indicative CHF amount");
|
2026-08-15 00:54:05 +02:00
|
|
|
|
Check(page.find("indicative") != std::string::npos,
|
|
|
|
|
|
"conversion is labelled indicative");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-17 11:04:03 +02:00
|
|
|
|
// A two-unit white export order: unit €665.38, line €1330.76, net from the
|
|
|
|
|
|
// LINE total (not per unit) = €1099.80, plus €55 world shipping = €1154.80.
|
2026-08-15 00:54:05 +02:00
|
|
|
|
const std::string tokenWhite = TokenOf(srv.Post("/shop/fp6-pmos",
|
2026-08-18 11:12:24 +02:00
|
|
|
|
"email=w%40example.org&name=W&street=X%201&postal=1&city=Y&country=CH&color=white&quantity=2"));
|
2026-08-15 00:54:05 +02:00
|
|
|
|
Check(!tokenWhite.empty(), "white ×2 checkout issues an order");
|
|
|
|
|
|
if (!tokenWhite.empty()) {
|
|
|
|
|
|
const std::string page = srv.Body(std::format("/order/{}", tokenWhite));
|
2026-08-17 11:04:03 +02:00
|
|
|
|
Check(page.find("€1154.80") != std::string::npos,
|
2026-08-15 00:54:05 +02:00
|
|
|
|
"white ×2 export total nets the line, not the unit");
|
|
|
|
|
|
Check(page.find("Device × 2") != std::string::npos, "order page shows the quantity");
|
|
|
|
|
|
Check(page.find("White") != std::string::npos, "order page names the colour");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// A colour we never listed must not buy anything, whatever the form claims.
|
|
|
|
|
|
srv.CheckStatus("/shop/fp6-pmos", "422", "POST", Good("&color=mauve"));
|
|
|
|
|
|
srv.CheckStatus("/shop/fp6-pmos", "422", "POST", Good("&quantity=100"));
|
|
|
|
|
|
srv.CheckStatus("/shop/fp6-pmos", "422", "POST", Good("&quantity=0"));
|
|
|
|
|
|
// Quantity is a free input with a technical ceiling, not a dropdown — a
|
|
|
|
|
|
// nine-unit order is business, not fraud.
|
|
|
|
|
|
Check(!TokenOf(srv.Post("/shop/fp6-pmos", Good("&quantity=9"))).empty(),
|
|
|
|
|
|
"a nine-unit order goes through");
|
|
|
|
|
|
srv.BodyHas("/shop/fp6-pmos", "type=\"number\"", "quantity is a number input, not a dropdown");
|
|
|
|
|
|
// The ceiling is physical: the heaviest band any destination offers
|
|
|
|
|
|
// (10 kg in the fixture) divided by the boxed unit weight (700 g) = 14.
|
|
|
|
|
|
// The input advertises the BEST case across destinations; the per-country
|
|
|
|
|
|
// limit is enforced on submit, below.
|
|
|
|
|
|
srv.BodyHas("/shop/fp6-pmos", "max=\"14\"", "quantity input carries the one-parcel ceiling");
|
|
|
|
|
|
|
|
|
|
|
|
// One order is one parcel. Fifteen units is 10.5 kg, past every band the
|
|
|
|
|
|
// fixture has, so it must be refused rather than quoted a rate the
|
|
|
|
|
|
// carrier would not accept — and the refusal has to say what WOULD fit,
|
|
|
|
|
|
// or the buyer is left guessing.
|
|
|
|
|
|
{
|
|
|
|
|
|
const auto heavy = srv.Post("/shop/fp6-pmos", Good("&quantity=15"));
|
|
|
|
|
|
Check(heavy.status == "422", "an over-weight order is refused", heavy.status);
|
|
|
|
|
|
Check(heavy.body.find("up to 14 per order") != std::string::npos,
|
|
|
|
|
|
"the too-heavy refusal says what fits");
|
|
|
|
|
|
Check(heavy.body.find("orders@catcrafts.net") != std::string::npos,
|
|
|
|
|
|
"the too-heavy refusal offers a way to order anyway");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// A destination the carrier has no rate for. Since the zone fallback went
|
|
|
|
|
|
// away there is no price to invent, so this is a refusal — and
|
|
|
|
|
|
// specifically NOT the no-sale refusal, which is a different (policy)
|
|
|
|
|
|
// reason with different wording.
|
|
|
|
|
|
constexpr std::string_view kAu =
|
|
|
|
|
|
"email=au%40example.org&name=Alex&street=1%20George%20St&postal=2000&city=Sydney&country=AU";
|
|
|
|
|
|
{
|
|
|
|
|
|
const auto au = srv.Post("/shop/fp6-pmos", std::string(kAu));
|
|
|
|
|
|
Check(au.status == "422", "an uncovered destination is refused", au.status);
|
|
|
|
|
|
Check(au.body.find("No carrier rate for AU") != std::string::npos,
|
|
|
|
|
|
"an uncovered destination is refused, naming the country");
|
|
|
|
|
|
// The country FIELD ERROR must be the carrier message, not the
|
|
|
|
|
|
// no-sale one. Matched on the error markup rather than the bare
|
|
|
|
|
|
// sentence: the no-sale line is standing copy above every buy form.
|
|
|
|
|
|
Check(au.body.find("field__error\">Catcrafts does not sell") == std::string::npos,
|
|
|
|
|
|
"an uncovered destination is not confused with a refused one");
|
|
|
|
|
|
const std::size_t before = LedgerLines(srv).size();
|
|
|
|
|
|
srv.Post("/shop/fp6-pmos", std::string(kAu));
|
|
|
|
|
|
Check(LedgerLines(srv).size() == before, "a refused destination writes no order");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// No invoice exists before the money does — awaiting orders answer 404.
|
|
|
|
|
|
srv.CheckStatus(std::format("/order/{}/invoice.md", token), "404");
|
|
|
|
|
|
srv.CheckStatus("/order/00000000000000000000000000000000/invoice.md", "404");
|
|
|
|
|
|
|
|
|
|
|
|
// ── the payment lands ─────────────────────────────────────────────
|
|
|
|
|
|
// Create the fake rail's paid marker, then the reconciler (1 s cadence in
|
|
|
|
|
|
// fake mode) must flip the order within a few seconds. The paid state
|
|
|
|
|
|
// shows the confirmation notice, deliberately WITHOUT a second "paid"
|
|
|
|
|
|
// badge — so the success marker is the notice text.
|
|
|
|
|
|
WriteFile(std::filesystem::path(srv.Orders().string() + ".fake-paid"), "");
|
|
|
|
|
|
{
|
|
|
|
|
|
const std::string page = srv.WaitForBody(orderPath, "order is confirmed");
|
|
|
|
|
|
Check(page.find("order is confirmed") != std::string::npos,
|
|
|
|
|
|
"order confirms after payment (arrival poll or reconciler)");
|
|
|
|
|
|
const std::size_t badges = CountOccurrences(page, "badge--active");
|
|
|
|
|
|
Check(badges == 0, "no duplicate paid badge next to the confirmation",
|
|
|
|
|
|
std::format("found {} active badges", badges));
|
|
|
|
|
|
Check(page.find("http-equiv=\"refresh\"") == std::string::npos,
|
|
|
|
|
|
"paid order page stops self-refreshing");
|
|
|
|
|
|
}
|
|
|
|
|
|
{
|
|
|
|
|
|
const std::string ledger = srv.OrdersText();
|
|
|
|
|
|
Check(ledger.find("\"type\":\"status\"") != std::string::npos
|
|
|
|
|
|
&& ledger.find("\"status\":\"paid\"") != std::string::npos,
|
|
|
|
|
|
"paid transition is an appended event, not a rewrite");
|
|
|
|
|
|
// The paid event records HOW it was paid — card money stays
|
|
|
|
|
|
// reversible for months, so the ledger must show which orders carry
|
|
|
|
|
|
// that tail.
|
|
|
|
|
|
Check(ledger.find("\"via\":\"fake\"") != std::string::npos,
|
|
|
|
|
|
"paid event records the payment method");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── the signed invoice ────────────────────────────────────────────
|
|
|
|
|
|
// Paid orders download a clearsigned markdown invoice: sequential number,
|
|
|
|
|
|
// registered identity, amounts — and a signature that verifies offline.
|
|
|
|
|
|
{
|
|
|
|
|
|
const auto invoice = srv.Get(std::format("/order/{}/invoice.md", token));
|
|
|
|
|
|
for (std::string_view probe : { "BEGIN PGP SIGNED MESSAGE", "# Invoice ",
|
|
|
|
|
|
"Customer number: ", "Chico Mendesring 256",
|
|
|
|
|
|
"KVK 78437059", "NL003329281B38", "CC-",
|
2026-08-17 11:04:03 +02:00
|
|
|
|
"VAT 21% (NL)", "€588.80" }) {
|
2026-08-15 00:54:05 +02:00
|
|
|
|
Check(invoice.body.find(probe) != std::string::npos,
|
|
|
|
|
|
std::format("invoice has {}", probe));
|
|
|
|
|
|
}
|
|
|
|
|
|
const auto disposition = invoice.headers.find("content-disposition");
|
|
|
|
|
|
Check(disposition != invoice.headers.end()
|
|
|
|
|
|
&& disposition->second.find("attachment") != std::string::npos,
|
|
|
|
|
|
"invoice downloads as an attachment");
|
|
|
|
|
|
const std::filesystem::path file = srv.Work() / "invoice.md";
|
|
|
|
|
|
WriteFile(file, invoice.body);
|
|
|
|
|
|
Check(GpgVerifies(file), "invoice signature verifies with gpg");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Several orders were placed before the marker (two of them by the same
|
|
|
|
|
|
// email); the arrival poll paid one instantly, the reconciler sweeps the
|
|
|
|
|
|
// rest on its 1 s cadence — wait for all four invoices before judging the
|
|
|
|
|
|
// numbering.
|
|
|
|
|
|
SettleUntil([&] {
|
|
|
|
|
|
return CountOccurrences(srv.OrdersText(), "\"type\":\"invoice\"") >= 4;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Per-customer series, continuing the pre-shop administration: numbers
|
|
|
|
|
|
// are <customer-uuid>-<seq>, unique overall, and orders that share an
|
|
|
|
|
|
// email share a series with distinct sequence numbers.
|
|
|
|
|
|
{
|
|
|
|
|
|
const std::string ledger = srv.OrdersText();
|
|
|
|
|
|
const std::size_t invoices = CountOccurrences(ledger, "\"type\":\"invoice\"");
|
|
|
|
|
|
std::set<std::string> numbers;
|
|
|
|
|
|
bool uuidSeries = false;
|
|
|
|
|
|
const std::regex numberField(R"lit("number":"([0-9a-f-]*)")lit");
|
|
|
|
|
|
const std::regex uuidSeq(
|
|
|
|
|
|
R"(^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12}-[0-9]+$)");
|
|
|
|
|
|
for (auto it = std::sregex_iterator(ledger.begin(), ledger.end(), numberField);
|
|
|
|
|
|
it != std::sregex_iterator(); ++it) {
|
|
|
|
|
|
numbers.insert((*it)[1].str());
|
|
|
|
|
|
uuidSeries = uuidSeries || std::regex_match((*it)[1].str(), uuidSeq);
|
|
|
|
|
|
}
|
|
|
|
|
|
Check(invoices > 0 && invoices == numbers.size(),
|
|
|
|
|
|
std::format("invoice numbers are unique ({} issued)", invoices));
|
|
|
|
|
|
Check(uuidSeries, "invoice numbers are customer-uuid series");
|
|
|
|
|
|
// The GOOD email placed several paid orders in this run — all of them
|
|
|
|
|
|
// must sit in ONE customer series (same uuid), with as many distinct
|
|
|
|
|
|
// sequence numbers.
|
|
|
|
|
|
std::set<std::string> customers;
|
|
|
|
|
|
const std::regex customerField(R"lit("customer":"([0-9a-f-]*)")lit");
|
|
|
|
|
|
for (auto it = std::sregex_iterator(ledger.begin(), ledger.end(), customerField);
|
|
|
|
|
|
it != std::sregex_iterator(); ++it) {
|
|
|
|
|
|
customers.insert((*it)[1].str());
|
|
|
|
|
|
}
|
|
|
|
|
|
Check(customers.size() < invoices,
|
|
|
|
|
|
std::format("repeat customer shares one series ({} customers, {} invoices)",
|
|
|
|
|
|
customers.size(), invoices));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── the confirmation email ────────────────────────────────────────
|
|
|
|
|
|
// Every paid order gets exactly one confirmation with the signed invoice
|
|
|
|
|
|
// attached. The expected count comes from the LEDGER rather than a number
|
|
|
|
|
|
// written here: "one per paid order" is the actual property, and a
|
|
|
|
|
|
// literal would have to be edited by anyone who adds an order above — a
|
|
|
|
|
|
// test that fails for the wrong reason and gets bumped without being
|
|
|
|
|
|
// read. The mailer sweeps every 2 s.
|
|
|
|
|
|
const std::size_t paidCount = CountOccurrences(srv.OrdersText(), "\"status\":\"paid\"");
|
|
|
|
|
|
SettleUntil([&] { return MailCount(srv) >= paidCount; }, 60);
|
|
|
|
|
|
const std::size_t mailCount = MailCount(srv);
|
|
|
|
|
|
Check(mailCount == paidCount,
|
|
|
|
|
|
std::format("one confirmation email per paid order ({} sent)", mailCount),
|
|
|
|
|
|
std::format("expected {}", paidCount));
|
|
|
|
|
|
|
|
|
|
|
|
// The NL order's message, found by its own order link (the same email
|
|
|
|
|
|
// address placed two orders, so the address alone would be ambiguous).
|
|
|
|
|
|
std::string nlMail;
|
2026-08-18 11:12:24 +02:00
|
|
|
|
std::string chMail;
|
2026-08-15 00:54:05 +02:00
|
|
|
|
for (const auto& entry : std::filesystem::directory_iterator(srv.Work())) {
|
|
|
|
|
|
const std::string name = entry.path().filename().string();
|
|
|
|
|
|
if (!name.starts_with("mail-") || !name.ends_with(".eml")) continue;
|
|
|
|
|
|
const std::string mail = ReadFile(entry.path());
|
|
|
|
|
|
if (mail.find(std::format("/order/{}", token)) != std::string::npos) nlMail = mail;
|
2026-08-18 11:12:24 +02:00
|
|
|
|
if (!tokenCh.empty()
|
|
|
|
|
|
&& mail.find(std::format("/order/{}", tokenCh)) != std::string::npos) {
|
|
|
|
|
|
chMail = mail;
|
2026-08-15 00:54:05 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
Check(!nlMail.empty(), "a confirmation email links the NL order");
|
|
|
|
|
|
if (!nlMail.empty()) {
|
|
|
|
|
|
for (std::string_view probe : { "To: e2e@example.org", "Subject: Catcrafts order CC-",
|
|
|
|
|
|
"From: Catcrafts <info@catcrafts.net>",
|
2026-08-17 11:04:03 +02:00
|
|
|
|
"MIME-Version: 1.0", "€588.80", "incl. 21% NL VAT",
|
2026-08-15 00:54:05 +02:00
|
|
|
|
"KVK 78437059", "BEGIN PGP SIGNED MESSAGE",
|
|
|
|
|
|
"filename=\"catcrafts-invoice-" }) {
|
|
|
|
|
|
Check(nlMail.find(probe) != std::string::npos,
|
|
|
|
|
|
std::format("email has {}", probe));
|
|
|
|
|
|
}
|
|
|
|
|
|
// The attached invoice must verify offline exactly like the download.
|
|
|
|
|
|
const std::size_t begin = nlMail.find("-----BEGIN PGP SIGNED MESSAGE-----");
|
|
|
|
|
|
const std::size_t end = nlMail.find("-----END PGP SIGNATURE-----");
|
|
|
|
|
|
Check(begin != std::string::npos && end != std::string::npos,
|
|
|
|
|
|
"email carries a full PGP block");
|
|
|
|
|
|
if (begin != std::string::npos && end != std::string::npos) {
|
|
|
|
|
|
const std::filesystem::path file = srv.Work() / "mail-invoice.asc";
|
|
|
|
|
|
WriteFile(file, nlMail.substr(
|
|
|
|
|
|
begin, end + std::string_view("-----END PGP SIGNATURE-----").size() - begin)
|
|
|
|
|
|
+ "\n");
|
|
|
|
|
|
Check(GpgVerifies(file), "emailed invoice signature verifies with gpg");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
// The export order's message states the VAT treatment its invoice carries.
|
2026-08-18 11:12:24 +02:00
|
|
|
|
Check(!chMail.empty() && chMail.find("zero-rated export") != std::string::npos,
|
2026-08-15 00:54:05 +02:00
|
|
|
|
"export confirmation states the zero-rated treatment");
|
|
|
|
|
|
|
|
|
|
|
|
// Idempotency comes from the ledger's notified event, not from luck in
|
|
|
|
|
|
// timing — sit out two more sweeps and expect no extra message.
|
|
|
|
|
|
std::this_thread::sleep_for(std::chrono::seconds(5));
|
|
|
|
|
|
Check(MailCount(srv) == mailCount, "no order was emailed twice",
|
|
|
|
|
|
std::format("message count grew from {} to {}", mailCount, MailCount(srv)));
|
|
|
|
|
|
Check(srv.OrdersText().find("\"type\":\"notified\"") != std::string::npos,
|
|
|
|
|
|
"notified events recorded in the ledger");
|
|
|
|
|
|
|
|
|
|
|
|
// ── financials reflect the ledger ─────────────────────────────────
|
|
|
|
|
|
// Lifetime sales on /financials must equal the ledger: sum of total_minor
|
|
|
|
|
|
// over orders that have a paid status event. Derived from the ledger
|
|
|
|
|
|
// rather than written as a literal — same rule as the email count above:
|
|
|
|
|
|
// "the page equals the ledger" is the actual property.
|
|
|
|
|
|
{
|
|
|
|
|
|
std::set<std::string> paidIds;
|
|
|
|
|
|
std::int64_t wantMinor = 0;
|
|
|
|
|
|
std::size_t wantCount = 0;
|
|
|
|
|
|
const std::vector<std::string> lines = LedgerLines(srv);
|
|
|
|
|
|
for (const std::string& line : lines) {
|
|
|
|
|
|
const auto event = Json::Parse(line);
|
|
|
|
|
|
if (!event || !event->IsObject()) continue;
|
|
|
|
|
|
if (event->Str("type") == "status" && event->Str("status") == "paid") {
|
|
|
|
|
|
paidIds.insert(std::string(event->Str("id")));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
for (const std::string& id : paidIds) {
|
|
|
|
|
|
for (const std::string& line : lines) {
|
|
|
|
|
|
const auto event = Json::Parse(line);
|
|
|
|
|
|
if (!event || !event->IsObject()) continue;
|
|
|
|
|
|
if (event->Str("type") == "order" && event->Str("id") == id) {
|
|
|
|
|
|
wantMinor += event->Int("total_minor");
|
|
|
|
|
|
++wantCount;
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
const std::string page = srv.Body("/financials");
|
|
|
|
|
|
const std::string want = std::format(
|
|
|
|
|
|
"data-fin-sales-minor=\"{}\"", wantMinor);
|
|
|
|
|
|
Check(wantCount > 0 && page.find(want) != std::string::npos
|
|
|
|
|
|
&& page.find(std::format("data-fin-sales-count=\"{}\"", wantCount))
|
|
|
|
|
|
!= std::string::npos,
|
|
|
|
|
|
std::format("sales totals equal the ledger ({} orders, {} cents)",
|
|
|
|
|
|
wantCount, wantMinor));
|
|
|
|
|
|
// And the formatted euro figure for that total appears on the page.
|
|
|
|
|
|
const std::string euro = wantMinor % 100 == 0
|
|
|
|
|
|
? std::format("€{}", wantMinor / 100)
|
|
|
|
|
|
: std::format("€{}.{:02}", wantMinor / 100, wantMinor % 100);
|
|
|
|
|
|
Check(page.find(euro) != std::string::npos,
|
|
|
|
|
|
std::format("sales total renders as {}", euro));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void ComingSoon(TestServer& srv) {
|
|
|
|
|
|
// A perfectly valid order must be refused while the shop is closed: after
|
|
|
|
|
|
// validation (so the field checks below still exercise the parser) and
|
|
|
|
|
|
// before any rail or ledger is touched.
|
|
|
|
|
|
srv.CheckStatus("/shop/fp6-pmos", "409", "POST", Good());
|
|
|
|
|
|
Check(srv.OrdersText().empty(), "refused order writes nothing to the ledger");
|
|
|
|
|
|
std::println("shop is coming-soon; the checkout, order-lifecycle and invoice "
|
|
|
|
|
|
"checks re-arm when the status flips to available");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void AlwaysOnValidation(TestServer& srv) {
|
|
|
|
|
|
srv.CheckStatus("/shop/fp6-pmos", "422", "POST",
|
|
|
|
|
|
"name=Ada&street=x&postal=1&city=y&country=NL"); // no email
|
|
|
|
|
|
srv.CheckStatus("/shop/fp6-pmos", "422", "POST",
|
|
|
|
|
|
"email=nonsense&" + Good()); // bad email (dup keeps first)
|
|
|
|
|
|
srv.CheckStatus("/shop/fp6-pmos", "422", "POST",
|
|
|
|
|
|
"email=a%40b.example&country=NL"); // missing address
|
|
|
|
|
|
srv.CheckStatus("/shop/fp6-pmos", "422", "POST", Good("&website=spam")); // honeypot
|
|
|
|
|
|
|
2026-08-18 11:12:24 +02:00
|
|
|
|
// Destinations the shop refuses (Money::SellsTo). Well-formed, real
|
|
|
|
|
|
// addresses: the refusal is policy, not a shape check, so it has to hold for
|
|
|
|
|
|
// every spelling the form accepts. Deliberately outside the shop-open gate —
|
|
|
|
|
|
// validation runs before the coming-soon check, so this must answer 422
|
|
|
|
|
|
// whether the shop is open or not, and it is the assertion that would catch
|
|
|
|
|
|
// the gate being lost in a refactor.
|
2026-08-15 00:54:05 +02:00
|
|
|
|
const std::size_t before = LedgerLines(srv).size();
|
|
|
|
|
|
srv.CheckStatus("/shop/fp6-pmos", "422", "POST",
|
|
|
|
|
|
"email=us%40example.org&name=Pat&street=1%20Main%20St&postal=43004&city=Columbus&country=US");
|
|
|
|
|
|
srv.CheckStatus("/shop/fp6-pmos", "422", "POST",
|
|
|
|
|
|
"email=ca%40example.org&name=Terry&street=1%20Bloor%20St&postal=M4W&city=Toronto&country=CA");
|
|
|
|
|
|
srv.CheckStatus("/shop/fp6-pmos", "422", "POST",
|
|
|
|
|
|
"email=us%40example.org&name=Pat&street=1%20Main%20St&postal=43004&city=Columbus&country=us");
|
2026-08-15 03:22:25 +02:00
|
|
|
|
// Sanctioned destinations (Money::SanctionedCountries) refuse through the
|
|
|
|
|
|
// same always-on gate — this refusal is the law, so of all the checks in
|
|
|
|
|
|
// this file it is the one that must survive every refactor.
|
|
|
|
|
|
srv.CheckStatus("/shop/fp6-pmos", "422", "POST",
|
|
|
|
|
|
"email=ru%40example.org&name=Sasha&street=1%20Tverskaya&postal=125009&city=Moscow&country=RU");
|
|
|
|
|
|
srv.CheckStatus("/shop/fp6-pmos", "422", "POST",
|
|
|
|
|
|
"email=by%40example.org&name=Vanya&street=1%20Kastrychnitskaya&postal=220030&city=Minsk&country=BY");
|
|
|
|
|
|
srv.CheckStatus("/shop/fp6-pmos", "422", "POST",
|
|
|
|
|
|
"email=ru%40example.org&name=Sasha&street=1%20Tverskaya&postal=125009&city=Moscow&country=ru");
|
2026-08-18 11:12:24 +02:00
|
|
|
|
// Destinations off the shipping allow-list (Money::ShipsTo). DE and GB are
|
|
|
|
|
|
// the pointed cases: the fixture PRICES both, so a rate exists and the parcel
|
|
|
|
|
|
// is postable — the refusal is entirely the policy's, which is the whole
|
|
|
|
|
|
// reason to assert these rather than a country the carrier never covered.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Status only, like every other refusal in this function. The exact sentence
|
|
|
|
|
|
// is asserted in ShouldValidateForms, where the validator is called directly:
|
|
|
|
|
|
// this function runs in the coming-soon state too, and a closed shop renders
|
|
|
|
|
|
// no order form for a field error to land in. What is worth proving over real
|
|
|
|
|
|
// HTTP is that the refusal holds at all, in both states — which is what 422
|
|
|
|
|
|
// says here.
|
|
|
|
|
|
srv.CheckStatus("/shop/fp6-pmos", "422", "POST",
|
|
|
|
|
|
"email=de%40example.org&name=Klaus&street=1%20Hauptstr&postal=10115&city=Berlin&country=DE");
|
|
|
|
|
|
srv.CheckStatus("/shop/fp6-pmos", "422", "POST",
|
|
|
|
|
|
"email=gb%40example.org&name=Terry&street=1%20Baker%20St&postal=W1U&city=London&country=GB");
|
|
|
|
|
|
srv.CheckStatus("/shop/fp6-pmos", "422", "POST",
|
|
|
|
|
|
"email=no%40example.org&name=Kari&street=1%20Karl%20Johans&postal=0154&city=Oslo&country=NO");
|
|
|
|
|
|
srv.CheckStatus("/shop/fp6-pmos", "422", "POST",
|
|
|
|
|
|
"email=tr%40example.org&name=Emre&street=1%20Istiklal&postal=34430&city=Istanbul&country=TR");
|
|
|
|
|
|
srv.CheckStatus("/shop/fp6-pmos", "422", "POST",
|
|
|
|
|
|
"email=br%40example.org&name=Ana&street=1%20Paulista&postal=01310&city=Sao%20Paulo&country=BR");
|
|
|
|
|
|
// And the default that makes an allow-list worth having: a well-formed code
|
|
|
|
|
|
// nobody ever considered is refused without appearing on any list.
|
|
|
|
|
|
srv.CheckStatus("/shop/fp6-pmos", "422", "POST",
|
|
|
|
|
|
"email=zz%40example.org&name=Sam&street=1%20Main&postal=0000&city=Nowhere&country=ZZ");
|
2026-08-15 00:54:05 +02:00
|
|
|
|
// Refused in validation means nothing reached the ledger and no payment
|
|
|
|
|
|
// link was ever created.
|
|
|
|
|
|
Check(LedgerLines(srv).size() == before, "a refused destination creates no order record");
|
|
|
|
|
|
srv.CheckStatus("/shop/nope", "404", "POST", Good()); // unknown product
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-17 11:04:03 +02:00
|
|
|
|
// The donation item is open in BOTH shop states — it is the soft opening the
|
|
|
|
|
|
// coming-soon phone waits behind — so this whole lifecycle runs
|
|
|
|
|
|
// unconditionally: create with a buyer-named amount, settle on the fake rail,
|
|
|
|
|
|
// confirm there is no invoice and no VAT, and watch /financials book it under
|
|
|
|
|
|
// donations rather than sales.
|
|
|
|
|
|
void DonationLifecycle(TestServer& srv) {
|
|
|
|
|
|
// ── validation ────────────────────────────────────────────────────
|
|
|
|
|
|
// The donation validator's refusals, over real HTTP. No address fields
|
|
|
|
|
|
// exist to miss; the amount is the field that carries the rules.
|
|
|
|
|
|
srv.CheckStatus("/shop/donation", "422", "POST", "email=a%40b.example"); // no amount
|
|
|
|
|
|
srv.CheckStatus("/shop/donation", "422", "POST", "amount=nonsense");
|
|
|
|
|
|
srv.CheckStatus("/shop/donation", "422", "POST", "amount=0.50"); // below €1
|
|
|
|
|
|
srv.CheckStatus("/shop/donation", "422", "POST", "amount=10000.01"); // above €10k
|
|
|
|
|
|
srv.CheckStatus("/shop/donation", "422", "POST", "amount=25&email=nonsense");
|
|
|
|
|
|
srv.CheckStatus("/shop/donation", "422", "POST", "amount=25&website=spam"); // honeypot
|
|
|
|
|
|
srv.CheckStatus("/shop/donation", "422", "POST", "amount=25&pay=free");
|
|
|
|
|
|
{
|
|
|
|
|
|
// A refused amount comes back in the form, like any rejected field.
|
|
|
|
|
|
const auto refused = srv.Post("/shop/donation", "amount=0.50");
|
|
|
|
|
|
Check(refused.body.find("field__error") != std::string::npos,
|
|
|
|
|
|
"a refused donation shows a field error");
|
|
|
|
|
|
Check(refused.body.find("€1 to €10000") != std::string::npos,
|
|
|
|
|
|
"the amount refusal names the bounds");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── a donation with no email at all ───────────────────────────────
|
|
|
|
|
|
// Identity is optional: the capability URL is the receipt.
|
|
|
|
|
|
const auto created = srv.Post("/shop/donation", "amount=25");
|
|
|
|
|
|
const std::string token = TokenOf(created);
|
|
|
|
|
|
Check(created.status == "303" && !token.empty(),
|
|
|
|
|
|
"POST donation -> 303 straight to payment", created.status);
|
|
|
|
|
|
{
|
|
|
|
|
|
const std::string line = [&] {
|
|
|
|
|
|
for (const std::string& l : LedgerLines(srv)) {
|
|
|
|
|
|
if (l.find(std::format("\"id\":\"{}\"", token)) != std::string::npos
|
|
|
|
|
|
&& l.find("\"type\":\"order\"") != std::string::npos) {
|
|
|
|
|
|
return l;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return std::string{};
|
|
|
|
|
|
}();
|
|
|
|
|
|
Check(!line.empty(), "the donation order is on the ledger");
|
|
|
|
|
|
for (std::string_view probe : { "\"donation\":true", "\"total_minor\":2500",
|
|
|
|
|
|
"\"shipping_minor\":0", "\"vat_included\":false",
|
|
|
|
|
|
"\"product\":\"donation\"", "\"email\":\"\"" }) {
|
|
|
|
|
|
Check(line.find(probe) != std::string::npos,
|
|
|
|
|
|
std::format("donation ledger line has {}", probe));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const std::string orderPath = std::format("/order/{}", token);
|
|
|
|
|
|
{
|
|
|
|
|
|
const std::string page = srv.Body(orderPath);
|
|
|
|
|
|
Check(page.find("€25") != std::string::npos,
|
|
|
|
|
|
"donation order page shows the amount");
|
|
|
|
|
|
Check(page.find("Shipping") == std::string::npos,
|
|
|
|
|
|
"donation order page has no shipping row");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── the payment lands ─────────────────────────────────────────────
|
|
|
|
|
|
// Same fake-rail marker as checkout (idempotent if the open-shop half
|
|
|
|
|
|
// already created it). The paid state is a thank-you, not a dispatch
|
|
|
|
|
|
// promise, and there is no invoice to download — not before, not after.
|
|
|
|
|
|
const std::size_t invoicesBefore =
|
|
|
|
|
|
CountOccurrences(srv.OrdersText(), "\"type\":\"invoice\"");
|
|
|
|
|
|
srv.CheckStatus(std::format("/order/{}/invoice.md", token), "404");
|
|
|
|
|
|
WriteFile(std::filesystem::path(srv.Orders().string() + ".fake-paid"), "");
|
|
|
|
|
|
{
|
|
|
|
|
|
const std::string page = srv.WaitForBody(orderPath, "Thank you");
|
|
|
|
|
|
Check(page.find("Thank you") != std::string::npos,
|
|
|
|
|
|
"a paid donation says thank you");
|
|
|
|
|
|
Check(page.find("VAT 0%") != std::string::npos,
|
|
|
|
|
|
"a paid donation states the 0% VAT treatment");
|
|
|
|
|
|
Check(page.find("Zero-rated export") == std::string::npos,
|
|
|
|
|
|
"a donation is not worded as an export");
|
|
|
|
|
|
Check(page.find("invoice.md") == std::string::npos,
|
|
|
|
|
|
"a paid donation offers no invoice download");
|
|
|
|
|
|
Check(page.find("flashed and tested") == std::string::npos,
|
|
|
|
|
|
"a paid donation promises no dispatch");
|
|
|
|
|
|
}
|
|
|
|
|
|
srv.CheckStatus(std::format("/order/{}/invoice.md", token), "404");
|
|
|
|
|
|
// Settle a moment: no invoice event may appear for a donation, ever.
|
|
|
|
|
|
SettleUntil([&] {
|
|
|
|
|
|
return srv.OrdersText().find(std::format("\"id\":\"{}\",\"status\":\"paid\"", token))
|
|
|
|
|
|
!= std::string::npos;
|
|
|
|
|
|
});
|
|
|
|
|
|
Check(CountOccurrences(srv.OrdersText(), "\"type\":\"invoice\"") == invoicesBefore,
|
|
|
|
|
|
"a paid donation is assigned no invoice number");
|
|
|
|
|
|
|
|
|
|
|
|
// ── /financials books it under donations ──────────────────────────
|
|
|
|
|
|
// Ledger-derived, same rule as the sales assertion: paid donation orders
|
|
|
|
|
|
// sum into the donations pair, paid goods orders into sales, and no euro
|
|
|
|
|
|
// sits in both.
|
|
|
|
|
|
{
|
|
|
|
|
|
std::set<std::string> paidIds;
|
|
|
|
|
|
const std::vector<std::string> lines = LedgerLines(srv);
|
|
|
|
|
|
for (const std::string& line : lines) {
|
|
|
|
|
|
const auto event = Json::Parse(line);
|
|
|
|
|
|
if (!event || !event->IsObject()) continue;
|
|
|
|
|
|
if (event->Str("type") == "status" && event->Str("status") == "paid") {
|
|
|
|
|
|
paidIds.insert(std::string(event->Str("id")));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
std::int64_t wantSales = 0, wantDonations = 0;
|
|
|
|
|
|
std::size_t wantSalesCount = 0, wantDonationCount = 0;
|
|
|
|
|
|
for (const std::string& line : lines) {
|
|
|
|
|
|
const auto event = Json::Parse(line);
|
|
|
|
|
|
if (!event || !event->IsObject()) continue;
|
|
|
|
|
|
if (event->Str("type") != "order"
|
|
|
|
|
|
|| !paidIds.contains(std::string(event->Str("id")))) {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (event->Bool("donation")) {
|
|
|
|
|
|
wantDonations += event->Int("total_minor");
|
|
|
|
|
|
++wantDonationCount;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
wantSales += event->Int("total_minor");
|
|
|
|
|
|
++wantSalesCount;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
const std::string page = srv.Body("/financials");
|
|
|
|
|
|
Check(wantDonationCount > 0, "at least one paid donation is on the ledger");
|
|
|
|
|
|
Check(page.find(std::format("data-fin-donations-count=\"{}\"", wantDonationCount))
|
|
|
|
|
|
!= std::string::npos
|
|
|
|
|
|
&& page.find(std::format("data-fin-donations-minor=\"{}\"", wantDonations))
|
|
|
|
|
|
!= std::string::npos,
|
|
|
|
|
|
std::format("donation totals equal the ledger ({} donations, {} cents)",
|
|
|
|
|
|
wantDonationCount, wantDonations));
|
|
|
|
|
|
Check(page.find(std::format("data-fin-sales-count=\"{}\"", wantSalesCount))
|
|
|
|
|
|
!= std::string::npos
|
|
|
|
|
|
&& page.find(std::format("data-fin-sales-minor=\"{}\"", wantSales))
|
|
|
|
|
|
!= std::string::npos,
|
|
|
|
|
|
"sales totals exclude the donation");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── the confirmation email ────────────────────────────────────────
|
|
|
|
|
|
// With an email given: a thank-you, no invoice attached (none exists).
|
|
|
|
|
|
// Without one: silence — no address means the donor asked for nothing.
|
|
|
|
|
|
const std::size_t mailsBefore = MailCount(srv);
|
|
|
|
|
|
const std::string tokenMailed = TokenOf(srv.Post("/shop/donation",
|
|
|
|
|
|
"amount=10&email=donor%40example.org"));
|
|
|
|
|
|
Check(!tokenMailed.empty(), "a donation with an email goes through");
|
|
|
|
|
|
SettleUntil([&] { return MailCount(srv) > mailsBefore; }, 60);
|
|
|
|
|
|
std::string donationMail;
|
|
|
|
|
|
for (const auto& entry : std::filesystem::directory_iterator(srv.Work())) {
|
|
|
|
|
|
const std::string name = entry.path().filename().string();
|
|
|
|
|
|
if (!name.starts_with("mail-") || !name.ends_with(".eml")) continue;
|
|
|
|
|
|
const std::string mail = ReadFile(entry.path());
|
|
|
|
|
|
if (mail.find(std::format("/order/{}", tokenMailed)) != std::string::npos) {
|
|
|
|
|
|
donationMail = mail;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
Check(!donationMail.empty(), "a donation with an email gets a confirmation");
|
|
|
|
|
|
if (!donationMail.empty()) {
|
|
|
|
|
|
for (std::string_view probe : { "To: donor@example.org",
|
|
|
|
|
|
"Subject: Catcrafts donation CC-",
|
|
|
|
|
|
"Thank you", "€10",
|
|
|
|
|
|
"No VAT applies" }) {
|
|
|
|
|
|
Check(donationMail.find(probe) != std::string::npos,
|
|
|
|
|
|
std::format("donation email has {}", probe));
|
|
|
|
|
|
}
|
|
|
|
|
|
Check(donationMail.find("BEGIN PGP SIGNED MESSAGE") == std::string::npos
|
|
|
|
|
|
&& donationMail.find("filename=\"catcrafts-invoice-") == std::string::npos,
|
|
|
|
|
|
"donation email attaches no invoice");
|
|
|
|
|
|
}
|
|
|
|
|
|
// The no-email donation stays unmailed: sit out two mailer sweeps and
|
|
|
|
|
|
// expect no message carrying its link.
|
|
|
|
|
|
std::this_thread::sleep_for(std::chrono::seconds(5));
|
|
|
|
|
|
bool mailedAnyway = false;
|
|
|
|
|
|
for (const auto& entry : std::filesystem::directory_iterator(srv.Work())) {
|
|
|
|
|
|
const std::string name = entry.path().filename().string();
|
|
|
|
|
|
if (!name.starts_with("mail-") || !name.ends_with(".eml")) continue;
|
|
|
|
|
|
if (ReadFile(entry.path()).find(std::format("/order/{}", token))
|
|
|
|
|
|
!= std::string::npos) {
|
|
|
|
|
|
mailedAnyway = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
Check(!mailedAnyway, "a donation without an email is never emailed");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-15 00:54:05 +02:00
|
|
|
|
// The re-rendered form only exists when the shop is open; while coming-soon a
|
|
|
|
|
|
// rejection answers with the coming-soon page instead.
|
|
|
|
|
|
void RejectedFormEcho(TestServer& srv) {
|
|
|
|
|
|
// A rejected submission must come back with the values still in it —
|
|
|
|
|
|
// losing a filled-in form is how a sale gets abandoned.
|
|
|
|
|
|
{
|
|
|
|
|
|
const auto rejected = srv.Post("/shop/fp6-pmos",
|
|
|
|
|
|
"email=bad&name=Ada&street=Main%201&postal=1234AB&city=Delft&country=NLD");
|
|
|
|
|
|
for (std::string_view probe : { "value=\"bad\"", "value=\"NLD\"", "value=\"Ada\"",
|
|
|
|
|
|
"value=\"Main 1\"", "value=\"Delft\"" }) {
|
|
|
|
|
|
Check(rejected.body.find(probe) != std::string::npos,
|
|
|
|
|
|
std::format("rejected form preserves {}", probe));
|
|
|
|
|
|
}
|
|
|
|
|
|
Check(rejected.body.find("field__error") != std::string::npos,
|
|
|
|
|
|
"rejected form shows a field error");
|
|
|
|
|
|
}
|
|
|
|
|
|
// A refused destination says why, in the form, with the address still in
|
|
|
|
|
|
// it — the visitor should learn the shop does not sell there, not that
|
|
|
|
|
|
// something went wrong.
|
|
|
|
|
|
{
|
|
|
|
|
|
const auto refused = srv.Post("/shop/fp6-pmos",
|
|
|
|
|
|
"email=us%40example.org&name=Pat&street=1%20Main%20St&postal=43004&city=Columbus&country=US");
|
|
|
|
|
|
Check(refused.body.find("does not sell or ship to the United States or Canada")
|
|
|
|
|
|
!= std::string::npos,
|
|
|
|
|
|
"refusal explains itself on the form");
|
|
|
|
|
|
Check(refused.body.find("value=\"Pat\"") != std::string::npos,
|
|
|
|
|
|
"a refused submission keeps what was typed");
|
|
|
|
|
|
}
|
2026-08-15 03:22:25 +02:00
|
|
|
|
// A sanctioned destination gets the sanctions sentence, not the policy
|
|
|
|
|
|
// one — the buyer should learn the law forbids the sale, not wonder what
|
|
|
|
|
|
// insurance has to do with Moscow.
|
|
|
|
|
|
{
|
|
|
|
|
|
const auto refused = srv.Post("/shop/fp6-pmos",
|
|
|
|
|
|
"email=ru%40example.org&name=Sasha&street=1%20Tverskaya&postal=125009&city=Moscow&country=RU");
|
|
|
|
|
|
Check(refused.body.find("EU sanctions prohibit") != std::string::npos,
|
|
|
|
|
|
"sanctions refusal explains itself on the form");
|
|
|
|
|
|
Check(refused.body.find("field__error\">Catcrafts does not sell") == std::string::npos,
|
|
|
|
|
|
"sanctions refusal is not worded as the policy one");
|
|
|
|
|
|
}
|
2026-08-15 00:54:05 +02:00
|
|
|
|
// The buy panel warns before anyone fills it in, and the preview script
|
2026-08-15 03:22:25 +02:00
|
|
|
|
// carries the same lists so it cannot quote a total the server would
|
2026-08-15 00:54:05 +02:00
|
|
|
|
// refuse.
|
|
|
|
|
|
srv.BodyHas("/shop/fp6-pmos", "does not sell or ship to the United States or Canada",
|
|
|
|
|
|
"buy panel states where the shop does not sell");
|
2026-08-15 03:22:25 +02:00
|
|
|
|
srv.BodyHas("/shop/fp6-pmos", "cannot sell or ship to Russia, Belarus or North Korea",
|
|
|
|
|
|
"buy panel states where the law forbids selling");
|
2026-08-15 00:54:05 +02:00
|
|
|
|
srv.BodyHas("/shop/fp6-pmos", ""x":["US","CA"]",
|
|
|
|
|
|
"total preview knows the refused destinations");
|
2026-08-15 03:22:25 +02:00
|
|
|
|
srv.BodyHas("/shop/fp6-pmos", ""s":["RU","BY","KP"]",
|
|
|
|
|
|
"total preview knows the sanctioned destinations");
|
2026-08-15 00:54:05 +02:00
|
|
|
|
// The honeypot message must not name the trap, or it teaches the next
|
|
|
|
|
|
// bot. Only the ERROR NOTICE is inspected: the re-rendered form
|
|
|
|
|
|
// legitimately contains the name="website" field itself — that IS the
|
|
|
|
|
|
// trap, re-armed.
|
|
|
|
|
|
{
|
|
|
|
|
|
const auto pot = srv.Post("/shop/fp6-pmos", Good("&website=x"));
|
|
|
|
|
|
std::smatch m;
|
|
|
|
|
|
Check(std::regex_search(pot.body, m, std::regex(R"lit(notice--error">([^<]*))lit")),
|
|
|
|
|
|
"honeypot rejection renders an error notice");
|
|
|
|
|
|
if (!m.empty()) {
|
|
|
|
|
|
std::string notice = m[1].str();
|
|
|
|
|
|
for (char& c : notice) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
|
|
|
|
|
|
const bool names = notice.find("honeypot") != std::string::npos
|
|
|
|
|
|
|| notice.find("website") != std::string::npos
|
|
|
|
|
|
|| notice.find("hidden") != std::string::npos
|
|
|
|
|
|
|| notice.find("trap") != std::string::npos;
|
|
|
|
|
|
Check(!names, "honeypot failure does not name the trap", m[1].str());
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
} // namespace
|
|
|
|
|
|
|
|
|
|
|
|
int main(int argc, char** argv) {
|
|
|
|
|
|
ServerOptions options;
|
|
|
|
|
|
options.gpg = true;
|
|
|
|
|
|
options.mailer = true;
|
|
|
|
|
|
TestServer srv(argv[1], 8217, options);
|
|
|
|
|
|
|
|
|
|
|
|
if (srv.ShopOpen()) {
|
|
|
|
|
|
OpenShopLifecycle(srv);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
ComingSoon(srv);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
AlwaysOnValidation(srv);
|
|
|
|
|
|
|
2026-08-17 11:04:03 +02:00
|
|
|
|
// The donation item is open in both shop states — that is the point of it.
|
|
|
|
|
|
DonationLifecycle(srv);
|
|
|
|
|
|
|
2026-08-15 00:54:05 +02:00
|
|
|
|
if (srv.ShopOpen()) {
|
|
|
|
|
|
RejectedFormEcho(srv);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return Finish();
|
|
|
|
|
|
}
|