donation item, shop soft open
All checks were successful
Deploy / build-deploy (push) Successful in 4m11s
All checks were successful
Deploy / build-deploy (push) Successful in 4m11s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
b0666841f6
commit
abbd616b40
23 changed files with 2898 additions and 209 deletions
|
|
@ -27,6 +27,27 @@ void Check(bool ok, std::string_view what, std::string_view got = {}) {
|
|||
got.empty() ? "" : " got: ", got);
|
||||
}
|
||||
|
||||
// The euro amount on the table row starting with `prefix`, in minor units, or
|
||||
// -1 when the row is missing. Reads the rendered document, not the builder's
|
||||
// internals: FormatEuro prints "€938.21", or "€580" when the cents are zero.
|
||||
std::int64_t RowMinor(const std::string& md, std::string_view prefix) {
|
||||
const std::size_t at = md.find(prefix);
|
||||
if (at == std::string::npos) return -1;
|
||||
std::size_t i = at + prefix.size();
|
||||
std::int64_t euros = 0;
|
||||
bool any = false;
|
||||
for (; i < md.size() && md[i] >= '0' && md[i] <= '9'; ++i) {
|
||||
euros = euros * 10 + (md[i] - '0');
|
||||
any = true;
|
||||
}
|
||||
if (!any) return -1;
|
||||
std::int64_t cents = 0;
|
||||
if (i + 2 < md.size() && md[i] == '.') {
|
||||
cents = (md[i + 1] - '0') * 10 + (md[i + 2] - '0');
|
||||
}
|
||||
return euros * 100 + cents;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
|
|
@ -65,6 +86,77 @@ int main() {
|
|||
Check(eu.find("€1135.23") != std::string::npos, "invoice: EU total");
|
||||
Check(eu.find("zero-rated") == std::string::npos, "invoice: EU is not an export");
|
||||
|
||||
// Every cent of the amounts table, pinned. This is the document a Dutch
|
||||
// buyer, an accountant and the Belastingdienst read, so a rounding change
|
||||
// in Money::NetFromGross must break a test rather than ship a wrong VAT
|
||||
// figure. Derived by hand from net = (gross*10000 + 6050) / 12100:
|
||||
// goods 112660 -> (1'126'600'000 + 6050) / 12100 = 93107 -> €931.07
|
||||
// total 113523 -> (1'135'230'000 + 6050) / 12100 = 93821 -> €938.21
|
||||
// VAT = 113523 - 93821 = 19702 -> €197.02
|
||||
// shipping = 93821 - 93107 = 714 -> €7.14
|
||||
// The shipping line is the REMAINDER of the subtotal, not a rounding of
|
||||
// its own — that is what makes the columns add up. Rounded independently
|
||||
// it would print €7.13 ((8'630'000 + 6050) / 12100 = 713) and sit a cent
|
||||
// below the subtotal, which is why the remainder rule exists: shipping
|
||||
// absorbs the cent so a signed tax document cannot disagree with itself.
|
||||
Check(eu.find("| Fairphone 6 — Forest Green | 2 | €931.07 |\n") != std::string::npos,
|
||||
"invoice: EU item line is net, not the gross the buyer paid");
|
||||
Check(eu.find("| Shipping | 1 | €7.14 |\n") != std::string::npos,
|
||||
"invoice: EU shipping line is the subtotal remainder");
|
||||
Check(eu.find("| Subtotal (ex VAT) | | €938.21 |\n") != std::string::npos,
|
||||
"invoice: EU subtotal is the net of the gross total");
|
||||
Check(eu.find("| VAT 21% (NL) | | €197.02 |\n") != std::string::npos,
|
||||
"invoice: EU VAT line is the amount actually remitted");
|
||||
Check(eu.find("| **Total (incl. VAT)** | | **€1135.23** |\n") != std::string::npos,
|
||||
"invoice: EU gross total is what was charged");
|
||||
|
||||
// The property the pinned cents above are one instance of, swept across
|
||||
// the realistic price grid: the three retail prices × every quantity a
|
||||
// parcel can carry × the range a shipping rate lives in. Before the
|
||||
// remainder rule, roughly a quarter of these combinations printed lines
|
||||
// one cent apart from their own subtotal (three independent half-up
|
||||
// roundings; two errors uniform on [-½,½) cross a boundary with
|
||||
// probability ¼). Rendered and re-parsed rather than recomputed, so what
|
||||
// is being held is the document itself:
|
||||
// item + shipping == subtotal (the remainder rule, by construction)
|
||||
// subtotal + VAT == total (what the buyer paid, to the cent)
|
||||
// |shipping - NetFromGross(shipping gross)| <= 1 (the cent stops here)
|
||||
{
|
||||
Server::OrderRecord s = o;
|
||||
std::string broke;
|
||||
for (const std::int64_t unit : { 57380, 57980, 66538 }) {
|
||||
for (std::int64_t qty = 1; qty <= 28; ++qty) {
|
||||
for (std::int64_t ship = 400; ship <= 6000; ship += 97) {
|
||||
s.quantity = qty;
|
||||
s.unitMinor = unit;
|
||||
s.goodsMinor = unit * qty;
|
||||
s.shippingMinor = ship;
|
||||
s.totalMinor = s.goodsMinor + ship;
|
||||
const std::string md = Server::BuildInvoiceMarkdown(s, "P", "");
|
||||
const std::int64_t item =
|
||||
RowMinor(md, std::format("| P | {} | €", qty));
|
||||
const std::int64_t shipping = RowMinor(md, "| Shipping | 1 | €");
|
||||
const std::int64_t sub = RowMinor(md, "| Subtotal (ex VAT) | | €");
|
||||
const std::int64_t vat = RowMinor(md, "| VAT 21% (NL) | | €");
|
||||
const std::int64_t total =
|
||||
RowMinor(md, "| **Total (incl. VAT)** | | **€");
|
||||
const bool ok = item >= 0 && shipping >= 0 && sub >= 0
|
||||
&& vat >= 0 && total == s.totalMinor
|
||||
&& item + shipping == sub
|
||||
&& sub + vat == total
|
||||
&& shipping - Money::NetFromGross(ship) <= 1
|
||||
&& Money::NetFromGross(ship) - shipping <= 1;
|
||||
if (!ok && broke.empty()) {
|
||||
broke = std::format("unit {} qty {} ship {}: {} + {} vs {}",
|
||||
unit, qty, ship, item, shipping, sub);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Check(broke.empty(),
|
||||
"invoice: EU columns add up across the whole price grid", broke);
|
||||
}
|
||||
|
||||
o.vatIncluded = false;
|
||||
o.buyer.country = "GB";
|
||||
o.goodsMinor = 93107;
|
||||
|
|
@ -75,6 +167,18 @@ int main() {
|
|||
Check(ex.find("art. 146") != std::string::npos, "invoice: export legal basis");
|
||||
Check(ex.find("€955.02") != std::string::npos, "invoice: export total");
|
||||
|
||||
// The mirror image of the EU table: a zero-rated export carries no VAT to
|
||||
// strip, so every line is the gross that was charged and NetFromGross must
|
||||
// never touch it. 93107 stays €931.07 (netting it again would print
|
||||
// €769.48) and 2395 stays €23.95 (€19.79 netted) — the two branches
|
||||
// swapping their treatment is exactly the accident these pin down.
|
||||
Check(ex.find("| Fairphone 6 — Forest Green | 2 | €931.07 |\n") != std::string::npos,
|
||||
"invoice: export item line stays gross");
|
||||
Check(ex.find("| Shipping | 1 | €23.95 |\n") != std::string::npos,
|
||||
"invoice: export shipping line stays gross");
|
||||
Check(ex.find("| **Total** | | **€955.02** |\n") != std::string::npos,
|
||||
"invoice: export total carries no VAT label");
|
||||
|
||||
// ── the order confirmation email ──────────────────────────────────
|
||||
// Same order, EU shape again; the attachment stands in for the
|
||||
// clearsigned invoice — the builder must carry it verbatim.
|
||||
|
|
@ -141,6 +245,88 @@ int main() {
|
|||
o, "F", "", "x", "u", "S", "D").empty(),
|
||||
"email: header-injecting address yields no message");
|
||||
|
||||
// The bare newline is only the loudest of the shapes that would widen the
|
||||
// envelope. Under `msmtp -t` the To: header IS the recipient list, so
|
||||
// every address Form::LooksLikeEmail rejects must yield NO message —
|
||||
// a comma is the cheapest extra-recipient smuggle of the lot, and it is
|
||||
// barred only because that shared form validator happens to bar it.
|
||||
// Pinning the coupling here means a future loosening of LooksLikeEmail
|
||||
// (a legitimate-looking change to a form helper) cannot quietly re-open
|
||||
// the envelope, and each of these carries a buyer's name and address.
|
||||
// "…, evil@…" comma, plus a second '@'
|
||||
// "…> , <evil@…" angle brackets, comma, second '@'
|
||||
// "…\rBcc: …" bare CR — a header break on its own under CRLF
|
||||
// "a@b" no dot in the domain
|
||||
// "" empty, below the minimum length
|
||||
for (const std::string_view addr : { "a@b.example, evil@x.example",
|
||||
"a@b.example> , <evil@x.example",
|
||||
"a@b.example\rBcc: x@y.example",
|
||||
"a@b",
|
||||
"" }) {
|
||||
o.buyer.email = std::string(addr);
|
||||
Check(Server::BuildOrderConfirmationEmail(
|
||||
o, "F", "", "x", "u", "S", "D").empty(),
|
||||
"email: address the envelope check rejects yields no message", addr);
|
||||
}
|
||||
|
||||
// ── the GPG key id alphabet ───────────────────────────────────────
|
||||
// gGpgKeyId is interpolated straight into a std::system() command line
|
||||
// between single quotes, so a single accepted quote character is remote
|
||||
// code execution as the shop user. The alphabet check in
|
||||
// ConfigureInvoicing is the entire defence. None of this reaches gpg:
|
||||
// a refused id leaves signing unconfigured, which is what we assert.
|
||||
Check(!Server::InvoiceSigningConfigured(), "invoice: signing starts unconfigured");
|
||||
for (const std::string_view bad : { "abc'; touch /tmp/pwned; '",
|
||||
"0xDEADBEEF BEEF",
|
||||
"0xDEADBEEF`id`",
|
||||
"0xDEADBEEF$(id)",
|
||||
"0xDEADBEEF\nBEEF" }) {
|
||||
Server::ConfigureInvoicing(std::string(bad));
|
||||
Check(!Server::InvoiceSigningConfigured(),
|
||||
"invoice: key id outside the safe alphabet is refused", bad);
|
||||
}
|
||||
|
||||
// The other half of the same contract, which the caller leans on: with no
|
||||
// signer installed the answer is refusal, never the plaintext. Returning
|
||||
// the markdown here would serve an UNSIGNED invoice through the path that
|
||||
// promises a signed one — and the caller cannot tell the difference.
|
||||
Check(!Server::ClearsignInvoice("# x").has_value(),
|
||||
"invoice: unconfigured signing yields nullopt, not the plaintext");
|
||||
|
||||
// What a fingerprint or a uid email actually needs: alnum plus @ . _ - +.
|
||||
Server::ConfigureInvoicing("0xDEADBEEF@catcrafts.net");
|
||||
Check(Server::InvoiceSigningConfigured(),
|
||||
"invoice: a key id inside the safe alphabet is accepted");
|
||||
// Put the process back the way we found it — nothing after this line
|
||||
// should be able to shell out to gpg.
|
||||
Server::ConfigureInvoicing("");
|
||||
Check(!Server::InvoiceSigningConfigured(),
|
||||
"invoice: an empty key id means no signing");
|
||||
|
||||
// ── MAIL_FROM is a header, and is guarded like one ────────────────
|
||||
// MAIL_FROM is written verbatim into the From: header of a message
|
||||
// delivered with `msmtp -t`, where the headers ARE the envelope: one
|
||||
// smuggled newline adds a recipient to EVERY order confirmation, and each
|
||||
// of those carries the buyer's name and full postal address.
|
||||
Check(!Server::MailConfigured(), "mail: starts unconfigured");
|
||||
Check(Server::MailFrom().empty(), "mail: no From before configuration");
|
||||
Server::ConfigureMail(Server::MailConfig{
|
||||
"true", "Catcrafts <info@catcrafts.net>\nBcc: leak@evil.example" });
|
||||
// Refused WHOLE, not sanitised: the guard returns before gMail is
|
||||
// assigned, so the command does not install either. A half-applied config
|
||||
// would be the dangerous outcome — a mailer that runs with a bad From.
|
||||
Check(!Server::MailConfigured(),
|
||||
"mail: a From with a line break rejects the whole config");
|
||||
Check(Server::MailFrom().empty(),
|
||||
"mail: a rejected From is never installed", Server::MailFrom());
|
||||
|
||||
// kSellerName + kSellerSite, so an operator who sets MAIL_COMMAND and
|
||||
// forgets MAIL_FROM still sends from an address that exists.
|
||||
Server::ConfigureMail(Server::MailConfig{ "true", "" });
|
||||
Check(Server::MailConfigured(), "mail: a clean config installs the command");
|
||||
Check(Server::MailFrom() == "Catcrafts <info@catcrafts.net>",
|
||||
"mail: empty MAIL_FROM defaults to the shop inbox", Server::MailFrom());
|
||||
|
||||
if (failures != 0) {
|
||||
std::println(std::cerr, "{} check(s) failed", failures);
|
||||
return 1;
|
||||
|
|
|
|||
|
|
@ -53,11 +53,60 @@ int main() {
|
|||
Check(NetFromGross(GrossFromNet(713)) == 713, "vat: gross-up round-trips");
|
||||
Check(GrossFromNet(100) == 121, "vat: €1.00 -> €1.21 exactly");
|
||||
Check(GrossFromNet(0) == 0, "vat: gross-up zero");
|
||||
// The half-up term (+5000) caught at sub-cent scale, once each way:
|
||||
// 3 × 1.21 = 3.63 must land on 4, 2 × 1.21 = 2.42 must land on 2. Plain
|
||||
// truncation would give 3 for the first, so this is what pins the term.
|
||||
Check(GrossFromNet(3) == 4, "vat: gross-up rounds .63 up");
|
||||
Check(GrossFromNet(2) == 2, "vat: gross-up rounds .42 down");
|
||||
// The round-trip is the property that makes "cost plus, eat nothing" true,
|
||||
// and it is applied to every EU carrier bracket — so it sets the shipping
|
||||
// cents on every EU order. If either rounding constant drifted, the shop
|
||||
// would remit VAT on a grossed-up rate that no longer nets back to the
|
||||
// carrier's own cost, losing or pocketing a cent on every parcel. A single
|
||||
// case cannot catch that; sweep the range a shipping rate lives in.
|
||||
//
|
||||
// Why it must hold for every n: GrossFromNet(n) is 1.21n rounded half up,
|
||||
// so it sits within 0.5 of 1.21n. Dividing back by 1.21 therefore lands
|
||||
// within 0.5/1.21 ≈ 0.413 of n — never far enough to reach the next
|
||||
// half-up boundary, so NetFromGross returns n exactly.
|
||||
{
|
||||
std::string broke;
|
||||
for (std::int64_t n = 0; n <= 2000; ++n) {
|
||||
if (NetFromGross(GrossFromNet(n)) != n && broke.empty()) {
|
||||
broke = std::format("net {} -> gross {} -> net {}", n,
|
||||
GrossFromNet(n), NetFromGross(GrossFromNet(n)));
|
||||
}
|
||||
}
|
||||
Check(broke.empty(),
|
||||
"vat: gross-up round-trips for every net from €0.00 to €20.00", broke);
|
||||
}
|
||||
|
||||
// ── zones and membership ──────────────────────────────────────────
|
||||
Check(IsEuCountry("NL") && IsEuCountry("DE") && IsEuCountry("FR"), "eu: members");
|
||||
// The whole roster, restated here rather than borrowed from EuCountries()
|
||||
// — a list that checks itself proves nothing. IsEuCountry is the single
|
||||
// switch in ComputeTotals between charging the VAT-inclusive price and
|
||||
// charging a zero-rated export net, so a member quietly lost to a rebase,
|
||||
// or typed as EL instead of GR, bills that country's buyers ~17% under the
|
||||
// order's worth while the shop still owes NL OSS VAT on the sale. Money
|
||||
// out the door, per order, with nothing else in the repo watching.
|
||||
constexpr std::array<std::string_view, 27> members{
|
||||
"AT", "BE", "BG", "HR", "CY", "CZ", "DE", "DK", "EE", "ES", "FI",
|
||||
"FR", "GR", "HU", "IE", "IT", "LT", "LU", "LV", "MT", "NL", "PL",
|
||||
"PT", "RO", "SE", "SI", "SK",
|
||||
};
|
||||
Check(EuCountries().size() == 27, "eu: 27 member states, no more and no fewer");
|
||||
for (const std::string_view cc : members) {
|
||||
Check(IsEuCountry(cc), "eu: member state recognised", cc);
|
||||
}
|
||||
Check(!IsEuCountry("GB"), "eu: UK left");
|
||||
Check(!IsEuCountry("CH") && !IsEuCountry("NO"), "eu: EFTA is not EU");
|
||||
// Three of the easiest false positives: IS shares the single market
|
||||
// through the EEA, UA and TR are candidates (TR is even in the customs
|
||||
// union). None of that is membership, and none of it makes a sale
|
||||
// domestic for VAT.
|
||||
Check(!IsEuCountry("IS"), "eu: EEA membership is not EU membership");
|
||||
Check(!IsEuCountry("UA") && !IsEuCountry("TR"), "eu: candidates are not members");
|
||||
Check(!IsEuCountry("CA") && !IsEuCountry("US"), "eu: north america");
|
||||
Check(!IsEuCountry("nl"), "eu: lowercase is not a member (normalise first)");
|
||||
Check(ZoneFor("NL") == Zone::Nl, "zone: home");
|
||||
|
|
@ -113,6 +162,25 @@ int main() {
|
|||
Check(LadderFor(table, "BR").empty(), "table: unlisted country is empty");
|
||||
}
|
||||
|
||||
// A zero-priced bracket, which is what a Sendcloud row with a missing or
|
||||
// unparseable price field becomes by the time it reaches here. RateFor
|
||||
// spends `best == 0` as its "nothing covers this weight" sentinel, so a
|
||||
// zero-cent band can never win — and that collision is doing real work: it
|
||||
// makes the lookup fail CLOSED (ShippingTable::Find returns 0, checkout
|
||||
// answers 422 and refuses) instead of shipping a €580 parcel worldwide for
|
||||
// nothing. It is an accident of the sentinel choice rather than a stated
|
||||
// rule, which is exactly why it needs a test standing over it: "prefer the
|
||||
// cheapest band" is a tempting simplification that would give the parcel
|
||||
// away.
|
||||
{
|
||||
const std::vector<ShipBracket> onlyFree{ { 2000, 0 } };
|
||||
Check(RateFor(onlyFree, 700) == 0,
|
||||
"brackets: a zero-priced band reads as no price, never as free");
|
||||
const std::vector<ShipBracket> withFree{ { 2000, 0 }, { 10000, 1650 } };
|
||||
Check(RateFor(withFree, 700) == 1650,
|
||||
"brackets: a real price wins over a zero-priced band that also carries it");
|
||||
}
|
||||
|
||||
// ── order totals ──────────────────────────────────────────────────
|
||||
|
||||
// NL: gross + shipping, VAT included in both.
|
||||
|
|
@ -141,10 +209,51 @@ int main() {
|
|||
auto nl2 = ComputeTotals(57500, 3, 1500, "NL");
|
||||
Check(nl2.goods == 172500 && nl2.total == 174000, "totals: qty multiplies gross");
|
||||
|
||||
// VAT is derived ONCE, from the taxable total — never from a sum of line
|
||||
// nets. ComputeTotals says so above (nl.vatCharged comes off goods +
|
||||
// shipping as one number); these four make the reason a test rather than a
|
||||
// comment, because NetFromGross is NOT additive across lines. Each division
|
||||
// rounds half up on its own remainder, and the remainders do not have to
|
||||
// agree:
|
||||
//
|
||||
// NetFromGross(56330) = (563'300'000 + 6050) / 12100 = 46554 rem 2650
|
||||
// NetFromGross( 400) = ( 4'000'000 + 6050) / 12100 = 331 rem 950
|
||||
// NetFromGross(56730) = (567'300'000 + 6050) / 12100 = 46884 rem 9650
|
||||
//
|
||||
// 56330 + 400 is 56730, but 46554 + 331 is 46885 — one cent ABOVE the net
|
||||
// the combined total yields. That gap is not exotic: across the real price
|
||||
// grid (three variants × 1..28 units × the shipping ladder) roughly a
|
||||
// quarter of the combinations hit it.
|
||||
//
|
||||
// It matters because the invoice prints a "Subtotal (ex VAT)" that is
|
||||
// NetFromGross of the whole total, with the VAT line derived from that
|
||||
// subtotal — and non-additivity is exactly why its shipping line is the
|
||||
// REMAINDER of that subtotal after the goods net, never NetFromGross of
|
||||
// the shipping on its own. The day someone "tidies" the remainder into a
|
||||
// third independent rounding, a GPG-signed tax document starts
|
||||
// disagreeing with itself by a cent on a quarter of the price grid.
|
||||
Check(NetFromGross(56330) == 46554, "vat: net of a goods line");
|
||||
Check(NetFromGross(400) == 331, "vat: net of a shipping line");
|
||||
Check(NetFromGross(56730) == 46884, "vat: net of the two taken together");
|
||||
Check(NetFromGross(56330) + NetFromGross(400) != NetFromGross(56730),
|
||||
"vat: line nets do not sum to the total net — derive VAT once, from the total");
|
||||
|
||||
// ── indicative conversion ─────────────────────────────────────────
|
||||
// €580.00 at 1.0834 USD/EUR = $628.37 -> 628 whole units.
|
||||
Check(ConvertIndicative(58000, 1'083'400) == 628, "fx: converts to whole units");
|
||||
Check(ConvertIndicative(58000, 1'000'000) == 580, "fx: identity rate");
|
||||
// Both cases above land far from the rounding boundary and would pass
|
||||
// under plain truncation too, which leaves the +50'000'000 term — the only
|
||||
// thing making this round rather than truncate — entirely unpinned. Drop
|
||||
// it and every quoted foreign price shifts DOWN by up to a whole unit, on
|
||||
// the number a non-euro buyer reads before deciding to order. So take the
|
||||
// boundary head-on: €1.00 at a rate of exactly 1.5 is 1.5 units.
|
||||
Check(ConvertIndicative(100, 1'500'000) == 2, "fx: exactly half rounds up");
|
||||
Check(ConvertIndicative(100, 1'499'999) == 1,
|
||||
"fx: one millionth below half rounds down");
|
||||
Check(ConvertIndicative(100, 1'400'000) == 1, "fx: .4 of a unit rounds down");
|
||||
// The term must not conjure a unit out of nothing, either.
|
||||
Check(ConvertIndicative(0, 1'083'400) == 0, "fx: zero converts to zero");
|
||||
auto gbp = CurrencyFor("GB");
|
||||
Check(gbp.has_value() && gbp->code == "GBP", "fx: GB -> GBP");
|
||||
Check(!CurrencyFor("DE").has_value(), "fx: euro country has no conversion");
|
||||
|
|
@ -172,6 +281,32 @@ int main() {
|
|||
Check(r.Find("XXX") == 0, "rates: absent is zero");
|
||||
Check(LoadRates("garbage").microPerEur.empty(), "rates: malformed input yields none");
|
||||
|
||||
// Above is the whole-document failure; this is the per-ENTRY one, which is
|
||||
// the case CI actually produces. LoadRates admits a rate only when it is a
|
||||
// JSON number AND strictly positive, and that guard is the single thing
|
||||
// standing between a bad rates.json and a printed price: the views and the
|
||||
// order handler only re-check `rate > 0` before formatting, so a negative
|
||||
// that slipped through here would render "≈ £-628" on every shop card.
|
||||
// One entry per rejected shape — zero, negative, a number sent as a
|
||||
// string, and null.
|
||||
const Rates bad = LoadRates(
|
||||
R"({"date":"2026-08-04","micro_per_eur":{"USD":0,"GBP":-860000,)"
|
||||
R"("CHF":"940000","SEK":null}})");
|
||||
Check(bad.date == "2026-08-04", "rates: a readable date survives unusable entries");
|
||||
Check(bad.microPerEur.empty(),
|
||||
"rates: zero, negative, string and null entries are all refused");
|
||||
Check(bad.Find("USD") == 0 && bad.Find("GBP") == 0 && bad.Find("CHF") == 0 &&
|
||||
bad.Find("SEK") == 0,
|
||||
"rates: a refused entry is indistinguishable from an absent one");
|
||||
// Refusal is per entry, not per document — one unusable rate must not take
|
||||
// its healthy siblings down with it, or a single ECB hiccup blanks every
|
||||
// localised price on the site instead of just the one currency's.
|
||||
const Rates partial = LoadRates(
|
||||
R"({"date":"2026-08-04","micro_per_eur":{"GBP":-860000,"NOK":11700000}})");
|
||||
Check(partial.microPerEur.size() == 1, "rates: only the bad entry is dropped");
|
||||
Check(partial.Find("NOK") == 11'700'000, "rates: the valid sibling still loads");
|
||||
Check(partial.Find("GBP") == 0, "rates: the negative sibling does not");
|
||||
|
||||
if (failures != 0) {
|
||||
std::println(std::cerr, "{} check(s) failed", failures);
|
||||
return 1;
|
||||
|
|
|
|||
|
|
@ -96,7 +96,7 @@ int main(int argc, char** argv) {
|
|||
}
|
||||
Check(listOk, "shop index carries an ItemList of the product pages");
|
||||
}
|
||||
srv.BodyHas("/shop/fp6-pmos", "\"price\":\"563.30\"", "schema price is the checkout integer");
|
||||
srv.BodyHas("/shop/fp6-pmos", "\"price\":\"573.80\"", "schema price is the checkout integer");
|
||||
// Merchant-grade offer fields: what Merchant Center's website-crawl feed
|
||||
// reads. Shipping is published from the live carrier table at one unit's
|
||||
// weight — the same integers checkout charges, so the listing cannot
|
||||
|
|
|
|||
|
|
@ -69,8 +69,25 @@ int main() {
|
|||
// ── Url ───────────────────────────────────────────────────────────
|
||||
CheckEq(Url("href", "/shop/thing"), " href=\"/shop/thing\"", "url: site-relative");
|
||||
CheckEq(Url("href", "https://a.example/x"), " href=\"https://a.example/x\"", "url: https");
|
||||
// Plain http is on the allowlist too. Not merely tolerated: a link the
|
||||
// author wrote as http must survive as http rather than turn into an
|
||||
// inert "#", because a silently dead link is worse than an insecure one.
|
||||
CheckEq(Url("href", "http://x.example/a"), " href=\"http://x.example/a\"", "url: http");
|
||||
CheckEq(Url("href", "mailto:a@b.example"), " href=\"mailto:a@b.example\"", "url: mailto");
|
||||
CheckEq(Url("href", "#reviews"), " href=\"#reviews\"", "url: fragment");
|
||||
// The EIP-681 pay link the crypto rail hands the buyer. Eurc builds it as
|
||||
// ethereum:{contract}@{chainId}/transfer?address={to}&uint256={units}
|
||||
// and the order page emits it through Url(). Two things must hold at once:
|
||||
// ethereum: stays on the allowlist (drop it and every crypto pay button
|
||||
// becomes href="#", i.e. nobody on that rail can pay), and the query
|
||||
// separator is still escaped to & like any other attribute value.
|
||||
CheckEq(Url("href", "ethereum:0xAbC@8453/transfer?address=0xDeF&uint256=1000000"),
|
||||
" href=\"ethereum:0xAbC@8453/transfer?address=0xDeF&uint256=1000000\"",
|
||||
"url: ethereum: EIP-681 kept verbatim, ampersand escaped");
|
||||
// Empty href fails every allowlist branch — including the site-relative
|
||||
// one, which needs at least one character — so it lands on "#" rather
|
||||
// than emitting a link that resolves to the current page.
|
||||
CheckEq(Url("href", ""), " href=\"#\"", "url: empty falls back to #");
|
||||
// Escaping alone would NOT make these safe: they contain no character
|
||||
// that needs escaping, so only a scheme allowlist stops them.
|
||||
CheckEq(Url("href", "javascript:alert(1)"), " href=\"#\"", "url: javascript: neutralised");
|
||||
|
|
@ -99,6 +116,65 @@ int main() {
|
|||
CheckEq(Join(std::span<const Html::SafeHtml>{}), "", "join: empty");
|
||||
CheckEq(Escape("a") + Escape("<"), "a<", "operator+: escapes preserved");
|
||||
|
||||
// ── Autolink: escaping ────────────────────────────────────────────
|
||||
// Autolink, not Escape, is what every prose paragraph on /legal/*, /about
|
||||
// and /financials goes through (Views), and what Markdown hands its
|
||||
// paragraph bodies. So it is the real escaper on those pages: if it ever
|
||||
// stops escaping, that is stored XSS on the policy text.
|
||||
CheckEq(Autolink("<b>a & b</b>"), "<b>a & b</b>",
|
||||
"autolink: escapes text with no URL in it");
|
||||
// With a URL present the non-URL runs still go through Escape. The '<'
|
||||
// also doubles as a URL terminator here, which is why the anchor stops
|
||||
// before "</b>" instead of swallowing it into the href.
|
||||
CheckEq(Autolink("<b>https://x.example</b>"),
|
||||
"<b><a href=\"https://x.example\">https://x.example</a></b>",
|
||||
"autolink: markup around a URL stays escaped");
|
||||
// The URL text is escaped on BOTH sides of the anchor — attribute and
|
||||
// text node — because href goes through Url() (which calls Attr, which
|
||||
// escapes) and the label goes through Escape(). A '&' in a query string
|
||||
// is the everyday case that proves it.
|
||||
CheckEq(Autolink("https://x.example/?a=1&b=2"),
|
||||
"<a href=\"https://x.example/?a=1&b=2\">https://x.example/?a=1&b=2</a>",
|
||||
"autolink: ampersand escaped in href and in anchor text");
|
||||
// An explicit http/https scheme is required, so nothing else becomes a
|
||||
// link — least of all a scheme Url() would have had to neutralise.
|
||||
CheckEq(Autolink("ftp://x.example/a"), "ftp://x.example/a",
|
||||
"autolink: non-http scheme is not linked");
|
||||
CheckEq(Autolink("javascript:alert(1)"), "javascript:alert(1)",
|
||||
"autolink: javascript: is text, never an anchor");
|
||||
|
||||
// ── Autolink: URL boundaries ──────────────────────────────────────
|
||||
// The privacy notice ends a sentence with a bare address. A period pulled
|
||||
// into the href is a 404 for every reader who clicks it, so the trailing
|
||||
// sentence punctuation is trimmed back out of the URL and re-emitted as
|
||||
// escaped text after the </a>.
|
||||
CheckEq(Autolink("See https://catcrafts.net/analytics."),
|
||||
"See <a href=\"https://catcrafts.net/analytics\">"
|
||||
"https://catcrafts.net/analytics</a>.",
|
||||
"autolink: trailing period stays outside the anchor");
|
||||
// Same rule for a closing bracket the URL did not open: "(see .../y)" has
|
||||
// zero '(' inside the matched run and one ')', so the ')' is given back.
|
||||
CheckEq(Autolink("(see https://x.example/y)"),
|
||||
"(see <a href=\"https://x.example/y\">https://x.example/y</a>)",
|
||||
"autolink: unmatched closing paren stays outside the anchor");
|
||||
// But a bracket the URL DID open is part of it — one '(' and one ')' in
|
||||
// the run, so the count test holds and the paren is kept in both href and
|
||||
// text. Wikipedia disambiguation links are the reason this rule exists.
|
||||
CheckEq(Autolink("https://en.wikipedia.org/wiki/Foo_(bar) end"),
|
||||
"<a href=\"https://en.wikipedia.org/wiki/Foo_(bar)\">"
|
||||
"https://en.wikipedia.org/wiki/Foo_(bar)</a> end",
|
||||
"autolink: balanced paren kept inside the anchor");
|
||||
// A scheme has to start a word. The "https://" here begins at index 1
|
||||
// with a letter before it, so it is the tail of a longer token, not a
|
||||
// link — the guard that stops two run-together URLs linking the second.
|
||||
CheckEq(Autolink("shttps://x.example"), "shttps://x.example",
|
||||
"autolink: scheme mid-word is not a link");
|
||||
// A scheme with no host after it: find("//") lands at 6 and 6+2 is not
|
||||
// less than the 8-char run, so the degenerate case emits escaped text and
|
||||
// advances pos past it. That advance is the loop-stall guard.
|
||||
CheckEq(Autolink("https://"), "https://",
|
||||
"autolink: bare scheme emits text and cannot stall the loop");
|
||||
|
||||
if (failures != 0) {
|
||||
std::println(std::cerr, "{} check(s) failed", failures);
|
||||
return 1;
|
||||
|
|
|
|||
592
tests/ShouldFoldTheOrderLedger/main.cpp
Normal file
592
tests/ShouldFoldTheOrderLedger/main.cpp
Normal file
|
|
@ -0,0 +1,592 @@
|
|||
/*
|
||||
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 order ledger: the append-only JSON-lines log and the left fold that turns
|
||||
// it back into orders. Everything downstream — the buyer's order page, the
|
||||
// invoice, the reconciler, the mailer, the public sales total on /financials —
|
||||
// reads whatever this fold says, and nothing else. So the properties pinned
|
||||
// here are the ones that decide whether an order exists, what it is worth, and
|
||||
// when it was paid.
|
||||
//
|
||||
// This suite drives the REAL storage functions against a scratch ledger rather
|
||||
// than constructing OrderRecords by hand: the interesting behaviour is in the
|
||||
// formatter and the fold, not in the struct.
|
||||
|
||||
import std;
|
||||
import Catcrafts.Shared;
|
||||
import Catcrafts.Server;
|
||||
|
||||
using namespace Catcrafts;
|
||||
|
||||
namespace {
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
int failures = 0;
|
||||
|
||||
void Check(bool ok, std::string_view what, std::string_view got = {}) {
|
||||
if (ok) return;
|
||||
++failures;
|
||||
std::println(std::cerr, "FAIL: {}{}{}", what,
|
||||
got.empty() ? "" : " got: ", got);
|
||||
}
|
||||
|
||||
fs::path gWork;
|
||||
|
||||
// The ledger path is process-global state, so every scenario gets its own file
|
||||
// rather than inheriting the previous one's history.
|
||||
fs::path FreshLedger(std::string_view name) {
|
||||
const fs::path p = gWork / std::format("{}.jsonl", name);
|
||||
std::error_code ec;
|
||||
fs::remove(p, ec);
|
||||
Server::SetOrdersPath(p);
|
||||
return p;
|
||||
}
|
||||
|
||||
// Hand-written ledgers: the fold's real input, byte for byte. Several
|
||||
// properties here (a truncated line, a replayed event, a line an older build
|
||||
// wrote) cannot be produced through CreateOrder at all.
|
||||
void WriteLedger(const fs::path& p, std::initializer_list<std::string_view> lines) {
|
||||
std::ofstream out(p, std::ios::trunc | std::ios::binary);
|
||||
for (const std::string_view line : lines) out << line << '\n';
|
||||
}
|
||||
|
||||
void AppendRaw(const fs::path& p, std::string_view line) {
|
||||
std::ofstream out(p, std::ios::app | std::ios::binary);
|
||||
out << line << '\n';
|
||||
}
|
||||
|
||||
std::string ReadAll(const fs::path& p) {
|
||||
std::ifstream in(p, std::ios::binary);
|
||||
return std::string(std::istreambuf_iterator<char>(in),
|
||||
std::istreambuf_iterator<char>());
|
||||
}
|
||||
|
||||
std::size_t CountOf(std::string_view hay, std::string_view needle) {
|
||||
std::size_t n = 0;
|
||||
for (std::size_t i = hay.find(needle); i != std::string_view::npos;
|
||||
i = hay.find(needle, i + needle.size())) {
|
||||
++n;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
// The single ledger line containing `needle`, without its terminator; empty
|
||||
// when no line has it.
|
||||
std::string LineContaining(std::string_view text, std::string_view needle) {
|
||||
std::size_t start = 0;
|
||||
while (start <= text.size()) {
|
||||
const std::size_t nl = text.find('\n', start);
|
||||
const std::size_t end = (nl == std::string_view::npos) ? text.size() : nl;
|
||||
const std::string_view line = text.substr(start, end - start);
|
||||
if (line.find(needle) != std::string_view::npos) return std::string(line);
|
||||
if (nl == std::string_view::npos) break;
|
||||
start = nl + 1;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
// A stored order with every field populated, so a scenario only has to say
|
||||
// what it cares about.
|
||||
Server::OrderRecord Sample(std::string token, std::string email) {
|
||||
Server::OrderRecord o;
|
||||
o.token = std::move(token);
|
||||
o.reference = Server::ReferenceFromToken(o.token);
|
||||
o.product = "fairphone-6";
|
||||
o.color = "green";
|
||||
o.quantity = 1;
|
||||
o.unitMinor = 56330;
|
||||
o.createdAt = "2026-08-15T09:00:00Z";
|
||||
o.buyer = { std::move(email), "Ada Lovelace", "Main St 1", "1234AB",
|
||||
"Delft", "NL" };
|
||||
o.goodsMinor = 56330;
|
||||
o.shippingMinor = 1500;
|
||||
o.totalMinor = 57830; // 56330 + 1500
|
||||
o.vatIncluded = true;
|
||||
o.payChoice = std::string(Form::kPayBank);
|
||||
o.payUrl = "https://pay.example.org/tr_test";
|
||||
o.payId = "tr_test";
|
||||
return o;
|
||||
}
|
||||
|
||||
// ── buyer free text cannot leave its field ────────────────────────────
|
||||
//
|
||||
// Form::ValidateCheckout length-limits name/street/postal/city and nothing
|
||||
// more, so quotes, backslashes and newlines reach CreateOrder's formatter
|
||||
// exactly as they were typed. JsonEscape is the only thing between them and
|
||||
// the record format.
|
||||
void BuyerTextStaysInItsField() {
|
||||
FreshLedger("escaping");
|
||||
|
||||
Server::OrderRecord o = Sample("1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a", "ada@example.org");
|
||||
// Shaped to close the name string and open a "total_minor":1 of its own.
|
||||
// Json::Value::Find returns the FIRST match for a key, so an unescaped
|
||||
// quote here would make this €578.30 order worth one cent — and
|
||||
// total_minor is what the invoice bills and what /financials publishes.
|
||||
o.buyer.name = R"(Ada ","total_minor":1,"x":")";
|
||||
|
||||
Check(!Server::FindOrder(o.token).has_value(),
|
||||
"ledger: a file that does not exist yet holds no orders");
|
||||
Check(Server::CreateOrder(o), "escaping: the order is written");
|
||||
|
||||
const std::optional<Server::OrderRecord> back = Server::FindOrder(o.token);
|
||||
Check(back.has_value(), "escaping: an injected name still parses as one record");
|
||||
if (back) {
|
||||
Check(back->buyer.name == o.buyer.name,
|
||||
"escaping: the name round-trips byte for byte", back->buyer.name);
|
||||
Check(back->totalMinor == 57830,
|
||||
"escaping: a buyer cannot mint their own total_minor",
|
||||
std::format("{}", back->totalMinor));
|
||||
}
|
||||
|
||||
// A backslash and a raw newline, on their own ledger so the line count
|
||||
// below means what it says.
|
||||
const fs::path solo = FreshLedger("escaping-newline");
|
||||
Server::OrderRecord n = Sample("2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b", "ada@example.org");
|
||||
n.buyer.street = R"(Main \ St 1)";
|
||||
n.buyer.city = "Delft\nNL";
|
||||
Check(Server::CreateOrder(n), "escaping: the order carrying a newline is written");
|
||||
|
||||
const std::optional<Server::OrderRecord> back2 = Server::FindOrder(n.token);
|
||||
Check(back2.has_value(), "escaping: a newline in the address does not lose the record");
|
||||
if (back2) {
|
||||
Check(back2->buyer.street == R"(Main \ St 1)",
|
||||
"escaping: a backslash survives the round trip", back2->buyer.street);
|
||||
Check(back2->buyer.city == "Delft\nNL",
|
||||
"escaping: an embedded newline survives the round trip");
|
||||
}
|
||||
// One record is one LINE. An unescaped newline would split this order in
|
||||
// two: the reader would keep the head and silently drop the address, the
|
||||
// total and the payment id.
|
||||
const std::string text = ReadAll(solo);
|
||||
Check(CountOf(text, "\n") == 1, "escaping: one order is exactly one line",
|
||||
std::format("{} line terminator(s)", CountOf(text, "\n")));
|
||||
Check(Server::ListOrders().size() == 1,
|
||||
"escaping: and the file folds back to exactly one order");
|
||||
}
|
||||
|
||||
// ── the sale is the FIRST paid event ──────────────────────────────────
|
||||
//
|
||||
// The join between the append-only log and the public sales figure. A refund
|
||||
// folds the status onward but never un-happens the payment, and a replayed
|
||||
// paid line must not be able to move the recorded moment of sale — the
|
||||
// timestamp both the bookkeeping and the invoice sequence hang off.
|
||||
void TheSaleIsTheFirstPaidEvent() {
|
||||
const fs::path led = FreshLedger("paid-then-cancelled");
|
||||
WriteLedger(led, {
|
||||
R"({"type":"order","at":"2025-12-31T23:00:00Z","id":"a1b2c3d4e5f60718293a4b5c6d7e8f90",)"
|
||||
R"("ref":"CC-A1B2C3","product":"fairphone-6","email":"ada@example.org",)"
|
||||
R"("total_minor":57830})",
|
||||
R"({"type":"status","at":"2026-01-01T00:00:00Z","id":"a1b2c3d4e5f60718293a4b5c6d7e8f90",)"
|
||||
R"("status":"paid","via":"ideal"})",
|
||||
// The same paid event again — a redelivered provider callback, or a
|
||||
// reconciler sweep that ran twice.
|
||||
R"({"type":"status","at":"2026-06-06T00:00:00Z","id":"a1b2c3d4e5f60718293a4b5c6d7e8f90",)"
|
||||
R"("status":"paid","via":"ideal"})",
|
||||
// Six months later the sale is refunded.
|
||||
R"({"type":"status","at":"2026-07-07T00:00:00Z","id":"a1b2c3d4e5f60718293a4b5c6d7e8f90",)"
|
||||
R"("status":"cancelled"})",
|
||||
});
|
||||
|
||||
const std::optional<Server::OrderRecord> o =
|
||||
Server::FindOrder("a1b2c3d4e5f60718293a4b5c6d7e8f90");
|
||||
Check(o.has_value(), "fold: the hand-written order resolves");
|
||||
if (o) {
|
||||
Check(o->paidAt == "2026-01-01T00:00:00Z",
|
||||
"fold: paidAt is the first paid event, not the replayed one", o->paidAt);
|
||||
Check(o->status == "cancelled", "fold: the latest status wins", o->status);
|
||||
Check(o->updatedAt == "2026-07-07T00:00:00Z",
|
||||
"fold: updatedAt follows the newest event folded in", o->updatedAt);
|
||||
// The refund carries no `via`, and losing the method here would erase
|
||||
// exactly which orders were settled with reversible money.
|
||||
Check(o->paidVia == "ideal", "fold: the settlement method outlives the refund",
|
||||
o->paidVia);
|
||||
}
|
||||
|
||||
// …and the refunded order is still a sale: /financials counts ever-paid.
|
||||
const std::vector<Server::OrderRecord> all = Server::ListOrders();
|
||||
const Server::SalesSummary sum = Server::SummarizeSales(all);
|
||||
Check(sum.count == 1 && sum.totalMinor == 57830,
|
||||
"fold: a refunded order still counts as a sale",
|
||||
std::format("count {} total {}", sum.count, sum.totalMinor));
|
||||
}
|
||||
|
||||
// ── invoice numbers are a sequence, per customer ──────────────────────
|
||||
//
|
||||
// Art. 226(2) permits "one or more series"; this is one series per customer,
|
||||
// keyed on the case-normalised email. Both the invoice download route and the
|
||||
// confirmation mailer call this on orders that may already carry a number, so
|
||||
// idempotency is not an optimisation — a second number for one sale means the
|
||||
// attached invoice stops matching the ledger.
|
||||
void InvoiceNumbersAreASequence() {
|
||||
const fs::path led = FreshLedger("invoice-idempotent");
|
||||
const Server::OrderRecord o = Sample("3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c", "ada@example.org");
|
||||
Check(Server::CreateOrder(o), "invoice: the order is written");
|
||||
|
||||
const std::optional<std::string> first =
|
||||
Server::AssignInvoiceNumber(o.token, "2026-08-15T10:00:00Z");
|
||||
Check(first.has_value(), "invoice: a stored order gets a number");
|
||||
if (first) {
|
||||
// "<uuid(36)>-<seq>": 36 + 1 + 1 for a customer's first invoice.
|
||||
Check(first->size() == 38, "invoice: <uuid>-<seq> shape", *first);
|
||||
Check((*first)[36] == '-', "invoice: the sequence hangs off a 36-char customer number",
|
||||
*first);
|
||||
Check(first->ends_with("-1"), "invoice: a new customer's series starts at 1", *first);
|
||||
}
|
||||
|
||||
const std::optional<std::string> again =
|
||||
Server::AssignInvoiceNumber(o.token, "2026-08-15T10:05:00Z");
|
||||
Check(again == first, "invoice: re-assigning returns the number already issued",
|
||||
again.value_or("<none>"));
|
||||
Check(CountOf(ReadAll(led), R"("type":"invoice")") == 1,
|
||||
"invoice: and appends no second invoice event");
|
||||
|
||||
// One customer who typed their address differently the second time is
|
||||
// still one customer, and so one series.
|
||||
FreshLedger("invoice-series");
|
||||
const Server::OrderRecord a = Sample("4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d", "Ada@Example.org");
|
||||
const Server::OrderRecord b = Sample("5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e", "ada@example.org");
|
||||
Check(Server::CreateOrder(a) && Server::CreateOrder(b),
|
||||
"invoice: both of the customer's orders are written");
|
||||
const std::optional<std::string> na =
|
||||
Server::AssignInvoiceNumber(a.token, "2026-08-15T10:00:00Z");
|
||||
const std::optional<std::string> nb =
|
||||
Server::AssignInvoiceNumber(b.token, "2026-08-16T10:00:00Z");
|
||||
Check(na.has_value() && nb.has_value(), "invoice: both orders get numbers");
|
||||
if (na && nb) {
|
||||
Check(na->substr(0, 36) == nb->substr(0, 36),
|
||||
"invoice: a differently-cased email is the same customer number",
|
||||
std::format("{} vs {}", *na, *nb));
|
||||
Check(na->ends_with("-1") && nb->ends_with("-2"),
|
||||
"invoice: the second sale continues the series rather than starting one",
|
||||
std::format("{} then {}", *na, *nb));
|
||||
}
|
||||
|
||||
// A token no order line names. Numbering an order that does not exist
|
||||
// would burn a member of the sequence on nothing.
|
||||
Check(!Server::AssignInvoiceNumber("deadbeefdeadbeefdeadbeefdeadbeef",
|
||||
"2026-08-15T10:00:00Z").has_value(),
|
||||
"invoice: no order, no number");
|
||||
}
|
||||
|
||||
// ── one bad line is only one bad line ─────────────────────────────────
|
||||
//
|
||||
// The design explicitly accepts a truncated last line from a crash mid-append.
|
||||
// If the fold aborted on a parse failure instead of skipping, one interrupted
|
||||
// write would 404 every order before it: buyers lose their status page, the
|
||||
// mailer stops, and /financials silently drops to zero.
|
||||
void OneBadLineIsOnlyOneBadLine() {
|
||||
const fs::path led = FreshLedger("corrupt");
|
||||
WriteLedger(led, {
|
||||
R"({"type":"order","at":"2026-08-01T09:00:00Z","id":"0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a",)"
|
||||
R"("ref":"CC-0A0A0A","product":"fairphone-6","email":"a@example.org",)"
|
||||
R"("total_minor":57830})",
|
||||
"not json at all",
|
||||
"",
|
||||
// A crash between the write and the newline.
|
||||
R"({"type":"order","id":"bbbb)",
|
||||
R"({"type":"order","at":"2026-08-02T09:00:00Z","id":"0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b",)"
|
||||
R"("ref":"CC-0B0B0B","product":"fairphone-6","email":"b@example.org",)"
|
||||
R"("total_minor":11111})",
|
||||
});
|
||||
|
||||
const std::vector<Server::OrderRecord> all = Server::ListOrders();
|
||||
Check(all.size() == 2, "ledger: three unreadable lines cost three lines and no more",
|
||||
std::format("{} record(s)", all.size()));
|
||||
if (all.size() == 2) {
|
||||
Check(all[0].token == "0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a"
|
||||
&& all[1].token == "0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b",
|
||||
"ledger: both good orders survive, in file order");
|
||||
Check(all[1].totalMinor == 11111,
|
||||
"ledger: the record after the truncated line is complete");
|
||||
}
|
||||
Check(Server::FindOrder("0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b").has_value(),
|
||||
"ledger: and it still resolves by token");
|
||||
|
||||
// An order event with no id names no order. Folding it in as a tokenless
|
||||
// record would give every later id-less event something to match.
|
||||
AppendRaw(led,
|
||||
R"({"type":"order","at":"2026-08-03T09:00:00Z","ref":"CC-NOID00",)"
|
||||
R"("product":"fairphone-6","email":"c@example.org","total_minor":100})");
|
||||
const std::vector<Server::OrderRecord> after = Server::ListOrders();
|
||||
Check(after.size() == 2, "ledger: an order event with no id is dropped",
|
||||
std::format("{} record(s)", after.size()));
|
||||
bool tokenless = false;
|
||||
for (const Server::OrderRecord& r : after) tokenless = tokenless || r.token.empty();
|
||||
Check(!tokenless, "ledger: no tokenless record is ever folded in");
|
||||
}
|
||||
|
||||
// ── history is not rewritten ──────────────────────────────────────────
|
||||
//
|
||||
// The file is append-only precisely so an amount cannot be changed after the
|
||||
// fact; first-order-event-wins is the enforcement. A later line that
|
||||
// overwrote the total would change what the invoice bills, what the
|
||||
// reconciler matches against the provider, and what /financials reports —
|
||||
// leaving no trace, since the original line still sits in the file.
|
||||
void HistoryIsNotRewritten() {
|
||||
const fs::path led = FreshLedger("duplicate-order");
|
||||
WriteLedger(led, {
|
||||
R"({"type":"order","at":"2026-08-01T09:00:00Z","id":"0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d",)"
|
||||
R"("ref":"CC-0D0D0D","product":"fairphone-6","email":"first@example.org",)"
|
||||
R"("total_minor":57830})",
|
||||
R"({"type":"order","at":"2026-08-01T09:05:00Z","id":"0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d",)"
|
||||
R"("ref":"CC-0D0D0D","product":"fairphone-6","email":"second@example.org",)"
|
||||
R"("total_minor":1})",
|
||||
});
|
||||
|
||||
const std::vector<Server::OrderRecord> all = Server::ListOrders();
|
||||
Check(all.size() == 1, "ledger: a duplicate order event yields one record, not two",
|
||||
std::format("{} record(s)", all.size()));
|
||||
if (all.size() == 1) {
|
||||
Check(all[0].totalMinor == 57830,
|
||||
"ledger: the first order event fixes the amount",
|
||||
std::format("{}", all[0].totalMinor));
|
||||
Check(all[0].buyer.email == "first@example.org",
|
||||
"ledger: and the buyer it was sold to", all[0].buyer.email);
|
||||
Check(all[0].createdAt == "2026-08-01T09:00:00Z",
|
||||
"ledger: and when the sale happened", all[0].createdAt);
|
||||
}
|
||||
}
|
||||
|
||||
// ── a ledger written by an older build still reads ────────────────────
|
||||
//
|
||||
// The module states this as a design guarantee, and it is the reason nothing
|
||||
// is ever rewritten in place: every key added since must default to something
|
||||
// an old line can live with.
|
||||
void AnOlderLedgerStillReads() {
|
||||
const fs::path led = FreshLedger("old-build");
|
||||
WriteLedger(led, {
|
||||
R"({"type":"order","at":"2026-02-02T08:00:00Z","id":"0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e",)"
|
||||
R"("ref":"CC-0E0E0E","product":"fairphone-6","email":"old@example.org",)"
|
||||
R"("total_minor":56330})",
|
||||
});
|
||||
|
||||
const std::optional<Server::OrderRecord> o =
|
||||
Server::FindOrder("0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e0e");
|
||||
Check(o.has_value(), "old ledger: a pre-variants order line still resolves");
|
||||
if (o) {
|
||||
// Int("quantity", 1) is the only thing standing between an old line
|
||||
// and a "× 0" on the buyer's page — and a zero-quantity line on an
|
||||
// invoice that is a legal document.
|
||||
Check(o->quantity == 1, "old ledger: an absent quantity reads as one, never zero",
|
||||
std::format("{}", o->quantity));
|
||||
// An empty status would make the order invisible to the reconciler
|
||||
// (not awaiting_payment) and to the mailer (not paid) at once.
|
||||
Check(o->status == "awaiting_payment",
|
||||
"old ledger: an absent status reads as awaiting payment", o->status);
|
||||
Check(o->totalMinor == 56330, "old ledger: what it does say is read");
|
||||
Check(o->color.empty(), "old ledger: pre-variants orders have no colour");
|
||||
Check(o->payChoice.empty(),
|
||||
"old ledger: the rail is read as written, with no default invented");
|
||||
Check(!o->vatIncluded, "old ledger: an absent vat_included is false");
|
||||
// Every order written before donations existed is a sale, or the
|
||||
// public sales total would quietly shrink under a newer build.
|
||||
Check(!o->donation, "old ledger: an absent donation flag reads as a sale");
|
||||
Check(o->unitMinor == 0 && o->goodsMinor == 0 && o->shippingMinor == 0,
|
||||
"old ledger: absent amounts are zero, not garbage");
|
||||
Check(o->createdAt == "2026-02-02T08:00:00Z" && o->updatedAt == o->createdAt,
|
||||
"old ledger: an order with no later event was last updated when it was made");
|
||||
Check(o->paidAt.empty() && o->invoiceNumber.empty() && o->confirmationSentAt.empty(),
|
||||
"old ledger: never paid, never invoiced, never emailed");
|
||||
}
|
||||
}
|
||||
|
||||
// ── only a confirmation marks the confirmation sent ───────────────────
|
||||
//
|
||||
// confirmationSentAt is the only thing that stops MailerLoop re-sending, and
|
||||
// the only thing that makes it send at all. "what" exists so the future
|
||||
// shipped-notice can share this event type; if the fold matched any "what",
|
||||
// that notice would mark the order as already notified and a buyer who paid
|
||||
// would get neither confirmation nor invoice.
|
||||
void OnlyAConfirmationMarksTheEmailSent() {
|
||||
const fs::path led = FreshLedger("notified");
|
||||
const Server::OrderRecord a = Sample("1111111111111111aaaaaaaaaaaaaaaa", "a@example.org");
|
||||
const Server::OrderRecord b = Sample("2222222222222222bbbbbbbbbbbbbbbb", "b@example.org");
|
||||
Check(Server::CreateOrder(a) && Server::CreateOrder(b),
|
||||
"notified: both orders are written");
|
||||
|
||||
Check(Server::AppendOrderNotified(a.token, "2026-08-15T10:00:00Z"),
|
||||
"notified: the event is appended");
|
||||
Check(CountOf(ReadAll(led), R"("what":"confirmation")") == 1,
|
||||
"notified: the writer names the message it sent");
|
||||
|
||||
const std::optional<Server::OrderRecord> ra = Server::FindOrder(a.token);
|
||||
Check(ra && ra->confirmationSentAt == "2026-08-15T10:00:00Z",
|
||||
"notified: the confirmation timestamp folds in",
|
||||
ra ? ra->confirmationSentAt : std::string("<no order>"));
|
||||
|
||||
// A different message about a different order.
|
||||
AppendRaw(led,
|
||||
R"({"type":"notified","at":"2026-08-15T11:00:00Z",)"
|
||||
R"("id":"2222222222222222bbbbbbbbbbbbbbbb","what":"shipped"})");
|
||||
const std::optional<Server::OrderRecord> rb = Server::FindOrder(b.token);
|
||||
Check(rb.has_value(), "notified: the second order still resolves");
|
||||
Check(rb && rb->confirmationSentAt.empty(),
|
||||
"notified: a shipped notice does not claim the confirmation was sent",
|
||||
rb ? rb->confirmationSentAt : std::string("<no order>"));
|
||||
const std::optional<Server::OrderRecord> ra2 = Server::FindOrder(a.token);
|
||||
Check(ra2 && ra2->confirmationSentAt == "2026-08-15T10:00:00Z",
|
||||
"notified: and it does not disturb the order that was confirmed");
|
||||
}
|
||||
|
||||
// ── a donation is income, never a sale ────────────────────────────────
|
||||
//
|
||||
// The donation flag decides three downstream behaviours at once (no invoice,
|
||||
// the thank-you email, and WHICH /financials row the money lands in), so what
|
||||
// is pinned here is the ledger's half: the flag round-trips through the
|
||||
// writer and the fold, an old line without the key stays a sale, and
|
||||
// SummarizeSales books each paid euro in exactly one row.
|
||||
void ADonationIsIncomeNeverASale() {
|
||||
const fs::path led = FreshLedger("donation");
|
||||
|
||||
Server::OrderRecord sale = Sample("6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f", "ada@example.org");
|
||||
Server::OrderRecord gift = Sample("7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a", "");
|
||||
gift.product = "donation";
|
||||
gift.donation = true;
|
||||
gift.color.clear();
|
||||
gift.buyer = { "", "", "", "", "", "" }; // nothing ships, nothing stored
|
||||
gift.unitMinor = 2500;
|
||||
gift.goodsMinor = 2500;
|
||||
gift.shippingMinor = 0;
|
||||
gift.totalMinor = 2500;
|
||||
gift.vatIncluded = false;
|
||||
Check(Server::CreateOrder(sale) && Server::CreateOrder(gift),
|
||||
"donation: both records are written");
|
||||
|
||||
// The writer follows the omit-rather-than-empty rule: the key exists only
|
||||
// on the donation's line, which is what keeps absent-means-false safe.
|
||||
const std::string text = ReadAll(led);
|
||||
Check(CountOf(text, R"("donation":true)") == 1,
|
||||
"donation: the flag is written once, on the donation's line");
|
||||
const std::string saleLine = LineContaining(text, sale.token);
|
||||
Check(!saleLine.empty() && saleLine.find(R"("donation")") == std::string::npos,
|
||||
"donation: a sale's line carries no donation key at all");
|
||||
|
||||
const std::optional<Server::OrderRecord> back = Server::FindOrder(gift.token);
|
||||
Check(back.has_value() && back->donation,
|
||||
"donation: the flag folds back in");
|
||||
const std::optional<Server::OrderRecord> saleBack = Server::FindOrder(sale.token);
|
||||
Check(saleBack.has_value() && !saleBack->donation,
|
||||
"donation: a sale folds back as one");
|
||||
|
||||
// Both paid: each euro lands in exactly one summary row.
|
||||
Check(Server::AppendOrderStatus(sale.token, "paid", "2026-08-17T10:00:00Z", "ideal")
|
||||
&& Server::AppendOrderStatus(gift.token, "paid", "2026-08-17T10:01:00Z",
|
||||
"eurc-base"),
|
||||
"donation: both paid transitions append");
|
||||
const Server::SalesSummary sum = Server::SummarizeSales(Server::ListOrders());
|
||||
Check(sum.count == 1 && sum.totalMinor == 57830,
|
||||
"donation: the sale row counts only the sale",
|
||||
std::format("count {} total {}", sum.count, sum.totalMinor));
|
||||
Check(sum.donationCount == 1 && sum.donationsMinor == 2500,
|
||||
"donation: the donation row counts only the donation",
|
||||
std::format("count {} total {}", sum.donationCount, sum.donationsMinor));
|
||||
|
||||
// An UNPAID donation is nothing yet — same ever-paid rule as sales.
|
||||
const fs::path led2 = FreshLedger("donation-unpaid");
|
||||
Server::OrderRecord pending = gift;
|
||||
pending.token = "8b8b8b8b8b8b8b8b8b8b8b8b8b8b8b8b";
|
||||
Check(Server::CreateOrder(pending), "donation: the unpaid donation is written");
|
||||
const Server::SalesSummary none = Server::SummarizeSales(Server::ListOrders());
|
||||
Check(none.donationCount == 0 && none.donationsMinor == 0,
|
||||
"donation: an unpaid donation counts nothing");
|
||||
}
|
||||
|
||||
// ── a transition keeps what it does not name ──────────────────────────
|
||||
//
|
||||
// paidVia is how the ledger shows at a glance which orders carry reversible
|
||||
// card money for months — the stated reason `via` exists at all. Losing it on
|
||||
// the next transition would erase that flag exactly when an order ships.
|
||||
void ATransitionKeepsWhatItDoesNotName() {
|
||||
const fs::path led = FreshLedger("status");
|
||||
const Server::OrderRecord o = Sample("f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0", "ada@example.org");
|
||||
Check(Server::CreateOrder(o), "status: the order is written");
|
||||
|
||||
Check(Server::AppendOrderStatus(o.token, "paid", "2026-08-15T09:00:00Z", "creditcard"),
|
||||
"status: the paid transition is appended");
|
||||
Check(Server::AppendOrderStatus(o.token, "shipped", "2026-08-15T11:00:00Z"),
|
||||
"status: the shipped transition is appended");
|
||||
|
||||
const std::optional<Server::OrderRecord> r = Server::FindOrder(o.token);
|
||||
Check(r.has_value(), "status: the order resolves");
|
||||
if (r) {
|
||||
Check(r->status == "shipped", "status: the latest transition wins", r->status);
|
||||
Check(r->paidVia == "creditcard",
|
||||
"status: the settlement method survives the next transition", r->paidVia);
|
||||
Check(r->paidAt == "2026-08-15T09:00:00Z",
|
||||
"status: shipping does not restate when it was paid", r->paidAt);
|
||||
Check(r->updatedAt == "2026-08-15T11:00:00Z",
|
||||
"status: updatedAt follows the transition", r->updatedAt);
|
||||
}
|
||||
|
||||
// The writer omits the key entirely rather than writing an empty one:
|
||||
// that is what makes "absent means unchanged" safe to rely on in the fold.
|
||||
const std::string text = ReadAll(led);
|
||||
const std::string shipped = LineContaining(text, R"("status":"shipped")");
|
||||
Check(!shipped.empty(), "status: the shipped transition is on the file");
|
||||
Check(shipped.find(R"("via")") == std::string::npos,
|
||||
"status: a transition with no method writes no via key at all", shipped);
|
||||
|
||||
// An empty status is not a state. Applying it would leave the order
|
||||
// invisible to the reconciler, the mailer and the invoice route at once.
|
||||
AppendRaw(led,
|
||||
R"({"type":"status","at":"2026-08-15T12:00:00Z",)"
|
||||
R"("id":"f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0","status":""})");
|
||||
const std::optional<Server::OrderRecord> blanked = Server::FindOrder(o.token);
|
||||
Check(blanked && blanked->status == "shipped",
|
||||
"status: an empty status is skipped, not applied",
|
||||
blanked ? blanked->status : std::string("<no order>"));
|
||||
Check(blanked && blanked->updatedAt == "2026-08-15T11:00:00Z",
|
||||
"status: and it does not even move updatedAt",
|
||||
blanked ? blanked->updatedAt : std::string("<no order>"));
|
||||
|
||||
// A transition naming an order that does not exist.
|
||||
AppendRaw(led,
|
||||
R"({"type":"status","at":"2026-08-15T13:00:00Z",)"
|
||||
R"("id":"deadbeefdeadbeefdeadbeefdeadbeef","status":"cancelled"})");
|
||||
const std::vector<Server::OrderRecord> all = Server::ListOrders();
|
||||
Check(all.size() == 1, "status: an event for an unknown order conjures no record",
|
||||
std::format("{} record(s)", all.size()));
|
||||
if (all.size() == 1) {
|
||||
Check(all[0].status == "shipped" && all[0].updatedAt == "2026-08-15T11:00:00Z",
|
||||
"status: and leaves the order that does exist alone");
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
std::error_code ec;
|
||||
// The suites run in parallel, so the scratch directory has to be unique
|
||||
// per run rather than merely per suite.
|
||||
gWork = fs::temp_directory_path(ec)
|
||||
/ std::format("catcrafts-ledger-{}", Server::NewOrderToken());
|
||||
fs::create_directories(gWork, ec);
|
||||
if (ec) {
|
||||
std::println(std::cerr, "could not create {}: {}", gWork.string(), ec.message());
|
||||
return 1;
|
||||
}
|
||||
|
||||
BuyerTextStaysInItsField();
|
||||
TheSaleIsTheFirstPaidEvent();
|
||||
InvoiceNumbersAreASequence();
|
||||
OneBadLineIsOnlyOneBadLine();
|
||||
HistoryIsNotRewritten();
|
||||
AnOlderLedgerStillReads();
|
||||
ADonationIsIncomeNeverASale();
|
||||
OnlyAConfirmationMarksTheEmailSent();
|
||||
ATransitionKeepsWhatItDoesNotName();
|
||||
|
||||
fs::remove_all(gWork, ec);
|
||||
|
||||
if (failures != 0) {
|
||||
std::println(std::cerr, "{} check(s) failed", failures);
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
425
tests/ShouldIssueEurcAddresses/main.cpp
Normal file
425
tests/ShouldIssueEurcAddresses/main.cpp
Normal file
|
|
@ -0,0 +1,425 @@
|
|||
/*
|
||||
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's address pool: which address a stranger is told to send money
|
||||
// to, and how many times that address may be told to anybody.
|
||||
//
|
||||
// There is no processor here to notice a mistake. Handing one address to two
|
||||
// orders means the second buyer's EURC settles the FIRST buyer's order while
|
||||
// the second lapses unpaid — real money at an address we control, and a
|
||||
// support ticket only a human can close. So the burn-before-handing-out order,
|
||||
// the persisted cursor, the resume after a restart, and every pool the rail
|
||||
// refuses to start with are all pinned here, alongside the EIP-681 amount the
|
||||
// buyer's wallet actually pre-fills.
|
||||
//
|
||||
// Nothing in this suite touches the network. The chains fixture points at
|
||||
// unreachable endpoints on purpose, and CheckPaid is exercised ONLY on payIds
|
||||
// that fail to split — the one branch that answers before an RPC is dialed.
|
||||
|
||||
import std;
|
||||
import Catcrafts.Shared;
|
||||
import Catcrafts.Server;
|
||||
|
||||
using namespace Catcrafts;
|
||||
|
||||
namespace {
|
||||
|
||||
int failures = 0;
|
||||
|
||||
void Check(bool ok, std::string_view what, std::string_view got = {}) {
|
||||
if (ok) return;
|
||||
++failures;
|
||||
std::println(std::cerr, "FAIL: {}{}{}", what,
|
||||
got.empty() ? "" : " got: ", got);
|
||||
}
|
||||
|
||||
void WriteFile(const std::filesystem::path& p, std::string_view content) {
|
||||
std::ofstream(p, std::ios::binary) << content;
|
||||
}
|
||||
|
||||
// "0x" + 40 hex digits, with a short tail naming the line it belongs to. Built
|
||||
// rather than typed out: a 40-character literal is exactly where a miscount
|
||||
// hides, and a pool line one digit short would exercise the refusal path
|
||||
// instead of whatever the assertion meant to prove.
|
||||
std::string Addr(std::string_view tail) {
|
||||
std::string s = "0x";
|
||||
s.append(40 - tail.size(), '0');
|
||||
s.append(tail);
|
||||
return s;
|
||||
}
|
||||
|
||||
// The rail parks its high-water mark beside the pool, as "<pool>.cursor".
|
||||
std::filesystem::path CursorOf(const std::filesystem::path& pool) {
|
||||
return std::filesystem::path(pool.string() + ".cursor");
|
||||
}
|
||||
|
||||
std::optional<std::size_t> CursorValue(const std::filesystem::path& pool) {
|
||||
std::ifstream in(CursorOf(pool), std::ios::binary);
|
||||
std::size_t v = 0;
|
||||
if (!(in >> v)) return std::nullopt;
|
||||
return v;
|
||||
}
|
||||
|
||||
std::string Show(const std::optional<std::size_t>& v) {
|
||||
return v ? std::to_string(*v) : std::string("(no cursor)");
|
||||
}
|
||||
|
||||
// The payId is "<address>@<unix-deadline>"; both halves are asserted
|
||||
// separately because they fail for different reasons.
|
||||
std::string AddressOf(std::string_view payId) {
|
||||
const std::size_t at = payId.rfind('@');
|
||||
if (at == std::string_view::npos) return {};
|
||||
return std::string(payId.substr(0, at));
|
||||
}
|
||||
|
||||
std::int64_t DeadlineOf(std::string_view payId) {
|
||||
const std::size_t at = payId.rfind('@');
|
||||
if (at == std::string_view::npos) return 0;
|
||||
const std::string_view digits = payId.substr(at + 1);
|
||||
std::int64_t v = 0;
|
||||
const auto [ptr, ec] =
|
||||
std::from_chars(digits.data(), digits.data() + digits.size(), v);
|
||||
if (ec != std::errc{} || ptr != digits.data() + digits.size()) return 0;
|
||||
return v;
|
||||
}
|
||||
|
||||
std::int64_t NowUnix() {
|
||||
return std::chrono::duration_cast<std::chrono::seconds>(
|
||||
std::chrono::system_clock::now().time_since_epoch()).count();
|
||||
}
|
||||
|
||||
Server::RailConfig Config(const std::filesystem::path& chains,
|
||||
const std::filesystem::path& pool,
|
||||
int windowHours = 24) {
|
||||
return Server::RailConfig{ .mode = "eurc",
|
||||
.eurcChainsPath = chains,
|
||||
.eurcPoolPath = pool,
|
||||
.eurcWindowHours = windowHours };
|
||||
}
|
||||
|
||||
// Two chains on purpose. The first carries a chain_id, so it renders a wallet
|
||||
// link; the second omits it, so the "watched but not linkable" branch is real
|
||||
// rather than hypothetical. Both endpoints are unreachable by design — a
|
||||
// suite that accidentally dialed one would be a suite that fails on a train.
|
||||
constexpr std::string_view kChains = R"({"chains":[
|
||||
{"name":"base","rpc":"https://rpc.invalid/base",
|
||||
"contract":"0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42",
|
||||
"decimals":6,"chain_id":8453,"note":"lowest fees"},
|
||||
{"name":"quiet","rpc":"https://rpc.invalid/quiet",
|
||||
"contract":"0x1aBaEA1f7C830bD89Acc67eC4af516284b1bC33c"}]})";
|
||||
|
||||
// Whatever the checkout hands in as the redirect. This rail has no hosted
|
||||
// page of its own, so this exact string is what must come back out.
|
||||
const std::string kOrderPage = "https://catcrafts.net/order/tok";
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
const std::filesystem::path root =
|
||||
std::filesystem::temp_directory_path() / "catcrafts-eurc-pool";
|
||||
std::error_code ec;
|
||||
std::filesystem::remove_all(root, ec);
|
||||
std::filesystem::create_directories(root, ec);
|
||||
|
||||
const std::filesystem::path chains = root / "chains.json";
|
||||
WriteFile(chains, kChains);
|
||||
|
||||
// ── one address, one order ────────────────────────────────────────
|
||||
{
|
||||
const std::filesystem::path pool = root / "issue.txt";
|
||||
// Line 1 is written in checksum case, the way a wallet exports it.
|
||||
// Everything downstream — the pool, the payId, the covering check —
|
||||
// compares the lowercase form, so that is what must come back out.
|
||||
WriteFile(pool, Addr("A1") + "\n" + Addr("b2") + "\n" + Addr("c3") + "\n");
|
||||
|
||||
std::unique_ptr<Server::PaymentRail> rail =
|
||||
Server::MakeEurcRail(Config(chains, pool));
|
||||
Check(rail != nullptr, "pool: a valid chains file and a valid pool load");
|
||||
if (rail) {
|
||||
Check(rail->Name() == "eurc", "rail: names itself — the ledger via prefix");
|
||||
Check(rail->PollInterval() == std::chrono::seconds(30),
|
||||
"rail: sweeps at a finality-shaped cadence, not a busy one");
|
||||
|
||||
const std::int64_t before = NowUnix();
|
||||
const std::optional<Server::PaymentLink> first =
|
||||
rail->CreateLink(57043, "desc", kOrderPage);
|
||||
const std::optional<Server::PaymentLink> second =
|
||||
rail->CreateLink(57043, "desc", kOrderPage);
|
||||
Check(first.has_value() && second.has_value(),
|
||||
"issue: two checkouts each get a payment link");
|
||||
if (first && second) {
|
||||
Check(AddressOf(first->payId) == Addr("a1"),
|
||||
"issue: the first order gets pool line 1, lowercased",
|
||||
first->payId);
|
||||
Check(AddressOf(second->payId) == Addr("b2"),
|
||||
"issue: the second order gets pool line 2", second->payId);
|
||||
// The assertion this whole rail exists to satisfy.
|
||||
Check(AddressOf(first->payId) != AddressOf(second->payId),
|
||||
"issue: never the same address twice");
|
||||
// There is no hosted checkout to send the buyer to: the order
|
||||
// page IS the payment page, so the caller's own URL comes back.
|
||||
Check(first->payUrl == kOrderPage,
|
||||
"issue: no provider page — the redirect is handed back",
|
||||
first->payUrl);
|
||||
// 24 configured hours = 86400 seconds past the moment of issue.
|
||||
// Bracketed by readings taken either side of the call, so the
|
||||
// bound is exact rather than a tolerance that could drift.
|
||||
const std::int64_t deadline = DeadlineOf(first->payId);
|
||||
Check(deadline >= before + 86400 && deadline <= NowUnix() + 86400,
|
||||
"issue: the deadline is the moment of issue plus the window",
|
||||
std::to_string(deadline - before));
|
||||
}
|
||||
|
||||
// Refused before the pool is touched. A zero or negative total is a
|
||||
// caller bug, and burning an address for one would spend the single
|
||||
// resource this rail cannot regenerate on its own.
|
||||
Check(!rail->CreateLink(0, "desc", kOrderPage).has_value(),
|
||||
"issue: a zero total buys no address");
|
||||
Check(!rail->CreateLink(-1, "desc", kOrderPage).has_value(),
|
||||
"issue: a negative total buys no address");
|
||||
}
|
||||
|
||||
// The cursor is on DISK, not merely in memory: it is the only thing
|
||||
// standing between a restart and republishing line 1 to a new buyer.
|
||||
Check(CursorValue(pool) == std::size_t{ 2 },
|
||||
"cursor: two issued, two burned — and the refusals burned none",
|
||||
Show(CursorValue(pool)));
|
||||
|
||||
// A fresh rail over the SAME two files must resume, never rewind.
|
||||
std::unique_ptr<Server::PaymentRail> restarted =
|
||||
Server::MakeEurcRail(Config(chains, pool));
|
||||
Check(restarted != nullptr, "restart: a partly spent pool still loads");
|
||||
if (restarted) {
|
||||
const std::optional<Server::PaymentLink> third =
|
||||
restarted->CreateLink(57043, "desc", kOrderPage);
|
||||
Check(third.has_value() && AddressOf(third->payId) == Addr("c3"),
|
||||
"restart: issues line 3, not line 1",
|
||||
third ? third->payId : std::string("no link"));
|
||||
}
|
||||
Check(CursorValue(pool) == std::size_t{ 3 },
|
||||
"cursor: the restarted rail advanced the same file",
|
||||
Show(CursorValue(pool)));
|
||||
}
|
||||
|
||||
// ── what the buyer's wallet is told to send ───────────────────────
|
||||
{
|
||||
const std::filesystem::path pool = root / "instructions.txt";
|
||||
WriteFile(pool, Addr("d1") + "\n" + Addr("d2") + "\n");
|
||||
std::unique_ptr<Server::PaymentRail> rail =
|
||||
Server::MakeEurcRail(Config(chains, pool));
|
||||
Check(rail != nullptr, "instructions: rail loads");
|
||||
if (rail) {
|
||||
const std::string payId = Addr("ab") + "@1800000000";
|
||||
const std::optional<Server::PayInstructions> ins =
|
||||
rail->Instructions(payId, 57043);
|
||||
Check(ins.has_value(), "instructions: a well-formed payId renders");
|
||||
if (ins) {
|
||||
Check(ins->address == Addr("ab"),
|
||||
"instructions: the address half is where the money goes",
|
||||
ins->address);
|
||||
// EURC is euro-denominated at par, so the token figure IS the
|
||||
// euro figure: 57043 cents shown as 570.43. No rate, no quote.
|
||||
Check(ins->amount == "570.43",
|
||||
"instructions: the amount is the euro total at par",
|
||||
ins->amount);
|
||||
Check(ins->deadlineUnix == 1800000000,
|
||||
"instructions: the deadline travels inside the id");
|
||||
Check(ins->chains.size() == 2,
|
||||
"instructions: every watched chain is offered, in file order",
|
||||
std::to_string(ins->chains.size()));
|
||||
}
|
||||
if (ins && ins->chains.size() == 2) {
|
||||
Check(ins->chains[0].name == "base"
|
||||
&& ins->chains[0].note == "lowest fees",
|
||||
"instructions: the first chain is the file's recommendation");
|
||||
Check(ins->chains[0].contract
|
||||
== "0x60a3e35cc302bfa44cb288bc5a4f316fdb1adb42",
|
||||
"instructions: contract lowercased for the buyer to compare",
|
||||
ins->chains[0].contract);
|
||||
// The uint256 is the literal quantity the wallet sends:
|
||||
// cents x 10^(decimals-2) = 57043 x 10^4 = 570430000 base
|
||||
// units. A wrong scale charges 10,000x too much or too little,
|
||||
// and the too-little case never satisfies the covering check in
|
||||
// CheckPaid — so the order lapses with real money already sat
|
||||
// at our address, which is the expensive direction.
|
||||
const std::string expected =
|
||||
"ethereum:0x60a3e35cc302bfa44cb288bc5a4f316fdb1adb42@8453"
|
||||
"/transfer?address=" + Addr("ab") + "&uint256=570430000";
|
||||
Check(ins->chains[0].link == expected,
|
||||
"instructions: EIP-681 link, amount in token base units",
|
||||
ins->chains[0].link);
|
||||
// chain_id 0 means "watched, but we cannot name the network in
|
||||
// a wallet link". The chain is still LISTED so the buyer can
|
||||
// pay there by hand — an empty link, never a guessed one.
|
||||
Check(ins->chains[1].name == "quiet" && ins->chains[1].link.empty(),
|
||||
"instructions: a chain without a chain_id is listed, unlinked",
|
||||
ins->chains[1].link);
|
||||
}
|
||||
// The same id in checksum case resolves to the same address: what
|
||||
// is published, compared and paid is always the lowercase form.
|
||||
const std::optional<Server::PayInstructions> upper =
|
||||
rail->Instructions(Addr("AB") + "@1800000000", 57043);
|
||||
Check(upper.has_value() && upper->address == Addr("ab"),
|
||||
"instructions: a mixed-case payId normalises to one address");
|
||||
|
||||
Check(!rail->Instructions(payId, 0).has_value(),
|
||||
"instructions: nothing to ask a buyer for at zero");
|
||||
Check(!rail->Instructions("garbage", 57043).has_value(),
|
||||
"instructions: an id that does not split renders nothing");
|
||||
}
|
||||
}
|
||||
|
||||
// ── an id that cannot identify a payment ──────────────────────────
|
||||
//
|
||||
// These are the ONLY CheckPaid inputs this suite may use: each fails to
|
||||
// split, and SplitPayId runs before BalanceOf, so the answer arrives
|
||||
// without a single RPC. A truncated or hand-edited ledger line must lapse
|
||||
// its order — returning Pending here would leave it awaiting payment
|
||||
// forever, and treating it as an address worth polling would ask a chain
|
||||
// about a string we never issued.
|
||||
{
|
||||
const std::filesystem::path pool = root / "dead.txt";
|
||||
WriteFile(pool, Addr("e1") + "\n");
|
||||
std::unique_ptr<Server::PaymentRail> rail =
|
||||
Server::MakeEurcRail(Config(chains, pool));
|
||||
Check(rail != nullptr, "dead: rail loads");
|
||||
if (rail) {
|
||||
for (const std::string& id : { std::string("not-an-id"),
|
||||
Addr("ab") + "@notanumber",
|
||||
std::string("0xdeadbeef@1800000000"),
|
||||
std::string("@1800000000"),
|
||||
Addr("ab") + "@" }) {
|
||||
const std::optional<Server::PaidStatus> st =
|
||||
rail->CheckPaid(id, 57043);
|
||||
Check(st.has_value() && st->state == Server::PayState::Dead
|
||||
&& st->method.empty(),
|
||||
"dead: a payId that does not split lapses the order", id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── pools this shop refuses to start with ─────────────────────────
|
||||
//
|
||||
// Each of these is a STARTUP refusal rather than a runtime surprise. The
|
||||
// failure a buyer would otherwise meet lands at the one moment they are
|
||||
// already committed, so it is moved to the moment the operator is watching.
|
||||
{
|
||||
// Lines 2 and 4 are one address in two casings. A chain does not care
|
||||
// about checksum case either, so a "different" line here is the
|
||||
// address-reuse bug wearing a disguise.
|
||||
const std::filesystem::path dup = root / "dup.txt";
|
||||
WriteFile(dup, Addr("11") + "\n" + Addr("AB") + "\n" + Addr("33") + "\n"
|
||||
+ Addr("ab") + "\n");
|
||||
Check(Server::MakeEurcRail(Config(chains, dup)) == nullptr,
|
||||
"refuse: a duplicate address, even in a different case");
|
||||
|
||||
// Fatal rather than skipped: a line that does not parse is as likely to
|
||||
// be a mangled good address as a stray note, and skipping it would
|
||||
// quietly shorten the list of places we can be paid.
|
||||
const std::filesystem::path bad = root / "bad.txt";
|
||||
WriteFile(bad, Addr("11") + "\n0xdeadbeef\n" + Addr("33") + "\n");
|
||||
Check(Server::MakeEurcRail(Config(chains, bad)) == nullptr,
|
||||
"refuse: a line that is not an address is fatal, never skipped");
|
||||
|
||||
const std::filesystem::path empty = root / "empty.txt";
|
||||
WriteFile(empty, "");
|
||||
Check(Server::MakeEurcRail(Config(chains, empty)) == nullptr,
|
||||
"refuse: an empty pool has nothing to hand out");
|
||||
|
||||
Check(Server::MakeEurcRail(Config(chains, root / "absent.txt")) == nullptr,
|
||||
"refuse: a pool file that does not exist");
|
||||
|
||||
const std::filesystem::path good = root / "good.txt";
|
||||
WriteFile(good, Addr("f1") + "\n");
|
||||
Check(Server::MakeEurcRail(Config(root / "absent.json", good)) == nullptr,
|
||||
"refuse: a chains file that does not exist");
|
||||
|
||||
// Strict about addresses, not about tidiness: the comment lines, blank
|
||||
// lines, indentation and CRLF a human produces while topping the pool
|
||||
// up from the wallet must not be mistaken for a bad pool.
|
||||
const std::filesystem::path messy = root / "messy.txt";
|
||||
WriteFile(messy, "# topped up 2026-08-15 from the cold wallet\n"
|
||||
"\n"
|
||||
" " + Addr("d4") + " # first of the batch\n"
|
||||
+ Addr("e5") + " \t\r\n"
|
||||
"\n");
|
||||
std::unique_ptr<Server::PaymentRail> tidy =
|
||||
Server::MakeEurcRail(Config(chains, messy));
|
||||
Check(tidy != nullptr, "accept: comments, blank lines, indent and trailing CR");
|
||||
if (tidy) {
|
||||
// Proves the stripping produced the ADDRESS and not the decoration
|
||||
// around it — a loader that stored " 0x…d4" would still "load".
|
||||
const std::optional<Server::PaymentLink> link =
|
||||
tidy->CreateLink(1000, "desc", kOrderPage);
|
||||
Check(link.has_value() && AddressOf(link->payId) == Addr("d4"),
|
||||
"accept: the comment and the indent are stripped, not stored",
|
||||
link ? link->payId : std::string("no link"));
|
||||
}
|
||||
|
||||
// Exhausted is a refusal, not a wrap-around. Wrapping would reissue
|
||||
// addresses already sitting in somebody's wallet app.
|
||||
const std::filesystem::path used = root / "used.txt";
|
||||
WriteFile(used, Addr("21") + "\n" + Addr("22") + "\n");
|
||||
WriteFile(CursorOf(used), "2\n");
|
||||
Check(Server::MakeEurcRail(Config(chains, used)) == nullptr,
|
||||
"refuse: the cursor says every address in the pool is spent");
|
||||
|
||||
// An unreadable cursor reads as exhausted, never as zero. Rewinding to
|
||||
// the top of a pool whose head is already published is the duplicate
|
||||
// bug again, arriving through a corrupted file instead of a typo.
|
||||
const std::filesystem::path garbled = root / "garbled.txt";
|
||||
WriteFile(garbled, Addr("31") + "\n" + Addr("32") + "\n");
|
||||
WriteFile(CursorOf(garbled), "x");
|
||||
Check(Server::MakeEurcRail(Config(chains, garbled)) == nullptr,
|
||||
"refuse: an unparseable cursor never rewinds to line 1");
|
||||
}
|
||||
|
||||
// ── the payment window ────────────────────────────────────────────
|
||||
{
|
||||
const std::filesystem::path pool = root / "window.txt";
|
||||
WriteFile(pool, Addr("91") + "\n" + Addr("92") + "\n");
|
||||
|
||||
// Zero hours is not "no window": there is no processor to expire
|
||||
// anything here, so an unset value has to mean the generous default
|
||||
// rather than a deadline that is already in the past at issue time.
|
||||
std::unique_ptr<Server::PaymentRail> dflt =
|
||||
Server::MakeEurcRail(Config(chains, pool, 0));
|
||||
Check(dflt != nullptr, "window: the default-window rail loads");
|
||||
if (dflt) {
|
||||
const std::int64_t before = NowUnix();
|
||||
const std::optional<Server::PaymentLink> link =
|
||||
dflt->CreateLink(1000, "desc", kOrderPage);
|
||||
Check(link.has_value()
|
||||
&& DeadlineOf(link->payId) >= before + 24 * 3600
|
||||
&& DeadlineOf(link->payId) <= NowUnix() + 24 * 3600,
|
||||
"window: an unset window is 24 hours, not zero");
|
||||
}
|
||||
|
||||
// The same pool, one address further along, with the hours set.
|
||||
std::unique_ptr<Server::PaymentRail> hour =
|
||||
Server::MakeEurcRail(Config(chains, pool, 1));
|
||||
Check(hour != nullptr, "window: a one-hour rail loads on the same pool");
|
||||
if (hour) {
|
||||
const std::int64_t before = NowUnix();
|
||||
const std::optional<Server::PaymentLink> link =
|
||||
hour->CreateLink(1000, "desc", kOrderPage);
|
||||
Check(link.has_value()
|
||||
&& DeadlineOf(link->payId) >= before + 3600
|
||||
&& DeadlineOf(link->payId) <= NowUnix() + 3600,
|
||||
"window: the configured hours are what the id carries");
|
||||
}
|
||||
}
|
||||
|
||||
std::filesystem::remove_all(root, ec);
|
||||
|
||||
if (failures != 0) {
|
||||
std::println(std::cerr, "{} check(s) failed", failures);
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
|
@ -94,8 +94,8 @@ void OpenShopLifecycle(TestServer& srv) {
|
|||
{
|
||||
const std::string ledger = srv.OrdersText();
|
||||
Check(ledger.find("\"country\":\"NL\"") != std::string::npos
|
||||
&& ledger.find("\"total_minor\":57830") != std::string::npos,
|
||||
"order stored: NL total is €578.30 (green €563.30 + €15 shipping)");
|
||||
&& ledger.find("\"total_minor\":58880") != std::string::npos,
|
||||
"order stored: NL total is €588.80 (green €573.80 + €15 shipping)");
|
||||
// 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.
|
||||
|
|
@ -109,7 +109,7 @@ void OpenShopLifecycle(TestServer& srv) {
|
|||
{
|
||||
const std::string page = srv.Body(orderPath);
|
||||
for (std::string_view probe : { "awaiting payment", "Resume payment", "CC-",
|
||||
"http-equiv=\"refresh\"", "€578.30" }) {
|
||||
"http-equiv=\"refresh\"", "€588.80" }) {
|
||||
Check(page.find(probe) != std::string::npos,
|
||||
std::format("order page has {}", probe));
|
||||
}
|
||||
|
|
@ -173,8 +173,8 @@ void OpenShopLifecycle(TestServer& srv) {
|
|||
Check(!tokenGb.empty(), "GB checkout issues an order");
|
||||
if (!tokenGb.empty()) {
|
||||
const std::string page = srv.Body(std::format("/order/{}", tokenGb));
|
||||
// €465.54 goods (green net) + €55 world shipping = €520.54
|
||||
Check(page.find("€520.54") != std::string::npos,
|
||||
// €474.21 goods (green net) + €55 world shipping = €529.21
|
||||
Check(page.find("€529.21") != std::string::npos,
|
||||
"export order total is ex-VAT + world shipping");
|
||||
Check(page.find("Zero-rated export") != std::string::npos,
|
||||
"export order states the VAT treatment");
|
||||
|
|
@ -184,14 +184,14 @@ void OpenShopLifecycle(TestServer& srv) {
|
|||
"conversion is labelled indicative");
|
||||
}
|
||||
|
||||
// A two-unit white export order: unit €665, line €1330, net from the LINE
|
||||
// total (not per unit) = €1082.45, plus €55 world shipping = €1137.45.
|
||||
// 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.
|
||||
const std::string tokenWhite = TokenOf(srv.Post("/shop/fp6-pmos",
|
||||
"email=w%40example.org&name=W&street=X%201&postal=1&city=Y&country=GB&color=white&quantity=2"));
|
||||
Check(!tokenWhite.empty(), "white ×2 checkout issues an order");
|
||||
if (!tokenWhite.empty()) {
|
||||
const std::string page = srv.Body(std::format("/order/{}", tokenWhite));
|
||||
Check(page.find("€1137.45") != std::string::npos,
|
||||
Check(page.find("€1154.80") != std::string::npos,
|
||||
"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");
|
||||
|
|
@ -286,7 +286,7 @@ void OpenShopLifecycle(TestServer& srv) {
|
|||
for (std::string_view probe : { "BEGIN PGP SIGNED MESSAGE", "# Invoice ",
|
||||
"Customer number: ", "Chico Mendesring 256",
|
||||
"KVK 78437059", "NL003329281B38", "CC-",
|
||||
"VAT 21% (NL)", "€578.30" }) {
|
||||
"VAT 21% (NL)", "€588.80" }) {
|
||||
Check(invoice.body.find(probe) != std::string::npos,
|
||||
std::format("invoice has {}", probe));
|
||||
}
|
||||
|
|
@ -372,7 +372,7 @@ void OpenShopLifecycle(TestServer& srv) {
|
|||
if (!nlMail.empty()) {
|
||||
for (std::string_view probe : { "To: e2e@example.org", "Subject: Catcrafts order CC-",
|
||||
"From: Catcrafts <info@catcrafts.net>",
|
||||
"MIME-Version: 1.0", "€578.30", "incl. 21% NL VAT",
|
||||
"MIME-Version: 1.0", "€588.80", "incl. 21% NL VAT",
|
||||
"KVK 78437059", "BEGIN PGP SIGNED MESSAGE",
|
||||
"filename=\"catcrafts-invoice-" }) {
|
||||
Check(nlMail.find(probe) != std::string::npos,
|
||||
|
|
@ -495,6 +495,186 @@ void AlwaysOnValidation(TestServer& srv) {
|
|||
srv.CheckStatus("/shop/nope", "404", "POST", Good()); // unknown product
|
||||
}
|
||||
|
||||
// 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");
|
||||
}
|
||||
|
||||
// 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) {
|
||||
|
|
@ -582,6 +762,9 @@ int main(int argc, char** argv) {
|
|||
|
||||
AlwaysOnValidation(srv);
|
||||
|
||||
// The donation item is open in both shop states — that is the point of it.
|
||||
DonationLifecycle(srv);
|
||||
|
||||
if (srv.ShopOpen()) {
|
||||
RejectedFormEcho(srv);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -104,6 +104,41 @@ void FinancialsPage() {
|
|||
Check(Money::FormatEuro(-26260) == "€-262.60" && Money::FormatEuro(-500) == "€-5.00",
|
||||
"financials: negative euro formatting");
|
||||
|
||||
// Donations paid through the shop join the bank-side ones in ONE row —
|
||||
// the reader has no use for a split by collection channel. The income
|
||||
// total and the net move with them.
|
||||
{
|
||||
const Views::RenderedPage both = Views::RenderFinancials(2, 113745, fin, 2, 5000);
|
||||
Check(both.main.View().find("data-fin-donations-count=\"5\"") != std::string_view::npos
|
||||
&& both.main.View().find("data-fin-donations-minor=\"9500\"")
|
||||
!= std::string_view::npos,
|
||||
"financials: shop and bank donations sum into one row");
|
||||
Check(both.main.View().find("Donations (5)") != std::string_view::npos,
|
||||
"financials: the donation row counts both sources");
|
||||
// Net = (4500 + 5000 + 113745) - 234800 = -111555.
|
||||
Check(both.main.View().find("data-fin-net-minor=\"-111555\"") != std::string_view::npos,
|
||||
"financials: the net includes shop donations");
|
||||
}
|
||||
// A shop donation shows the moment it is paid, even before any bank
|
||||
// figures exist — it is live from the order ledger, like sales. The
|
||||
// income total still waits for the bank side: a total missing half its
|
||||
// inputs is not a total.
|
||||
{
|
||||
const Views::RenderedPage shopOnly = Views::RenderFinancials(0, 0, Financials{}, 1, 2500);
|
||||
Check(shopOnly.main.View().find("data-fin-donations-count=\"1\"") != std::string_view::npos
|
||||
&& shopOnly.main.View().find("data-fin-donations-minor=\"2500\"")
|
||||
!= std::string_view::npos,
|
||||
"financials: a shop donation publishes without bank figures");
|
||||
Check(shopOnly.main.View().find("Donations (1)") != std::string_view::npos,
|
||||
"financials: and renders its row");
|
||||
// The Income SECTION heading always renders; what must wait for the
|
||||
// bank side is the ruled-off total row (and the net).
|
||||
Check(shopOnly.main.View().find(R"(<tr class="fin-total"><th scope="row">Income</th>)")
|
||||
== std::string_view::npos
|
||||
&& shopOnly.main.View().find("data-fin-net-minor") == std::string_view::npos,
|
||||
"financials: no income total or net while the bank side is unpublished");
|
||||
}
|
||||
|
||||
// Before the bank figures exist the page says so instead of lying
|
||||
// with zeros — and publishes no donation figures at all.
|
||||
const Views::RenderedPage bare = Views::RenderFinancials(0, 0, Financials{});
|
||||
|
|
@ -133,10 +168,19 @@ void FinancialsPage() {
|
|||
Server::OrderRecord shipped;
|
||||
shipped.totalMinor = 200;
|
||||
shipped.status = "shipped";
|
||||
const std::array<Server::OrderRecord, 4> orders{ paid, waiting, refunded, shipped };
|
||||
// A paid donation is income but not a sale: it must land in the donation
|
||||
// pair, or the page would book the same euro as a sale.
|
||||
Server::OrderRecord gift;
|
||||
gift.totalMinor = 2500;
|
||||
gift.donation = true;
|
||||
gift.paidAt = "2026-08-17T00:00:00Z";
|
||||
gift.status = "paid";
|
||||
const std::array<Server::OrderRecord, 5> orders{ paid, waiting, refunded, shipped, gift };
|
||||
const Server::SalesSummary sum = Server::SummarizeSales(orders);
|
||||
Check(sum.count == 3 && sum.totalMinor == 56330 + 56930 + 200,
|
||||
"financials: sales count ever-paid orders only");
|
||||
"financials: sales count ever-paid orders only, donations excluded");
|
||||
Check(sum.donationCount == 1 && sum.donationsMinor == 2500,
|
||||
"financials: a paid shop donation folds into the donation pair");
|
||||
Check(Server::SummarizeSales({}).count == 0,
|
||||
"financials: empty ledger sums to zero");
|
||||
}
|
||||
|
|
@ -178,6 +222,42 @@ void BunqIngest() {
|
|||
.has_value(),
|
||||
"bunq: a notification with no mutation yields nothing");
|
||||
|
||||
// The same payload with one field swapped, so every case below differs
|
||||
// from the parsing case above by exactly the thing under test.
|
||||
auto payloadWith = [](std::string_view amountObject, std::string_view alias) {
|
||||
std::string out;
|
||||
out += R"({"NotificationUrl":{"category":"MUTATION","object":{"Payment":{)";
|
||||
out += R"("id":4823,"created":"2026-08-14 09:31:02.123456",)";
|
||||
out += R"("monetary_account_id":9911,"amount":)";
|
||||
out += amountObject;
|
||||
out += R"(,"description":"Thanks for imsd!","counterparty_alias":)";
|
||||
out += alias;
|
||||
out += R"(}}}})";
|
||||
return out;
|
||||
};
|
||||
constexpr std::string_view kDonorAlias =
|
||||
R"({"iban":"NL55BUNQ2025123456","display_name":"A Donor"})";
|
||||
|
||||
// FindPaymentObject matches on the SHAPE — an amount object carrying a
|
||||
// value, plus an id — and never looks at what the value SAYS. So a
|
||||
// locale-mangled or hostile amount reaches the parser inside an otherwise
|
||||
// perfectly well-formed mutation, and the refusal has to happen here. If
|
||||
// it ever softened to a zero fallback the mutation would be recorded as
|
||||
// seen, permanently deduped, with the money dropped from the totals and
|
||||
// nothing in the operator log to say so.
|
||||
Check(!Server::ParseBunqMutation(
|
||||
payloadWith(R"({"currency":"EUR","value":"25,00"})", kDonorAlias))
|
||||
.has_value(),
|
||||
"bunq: a comma decimal is refused rather than read as zero");
|
||||
Check(!Server::ParseBunqMutation(
|
||||
payloadWith(R"({"currency":"EUR","value":"1.234"})", kDonorAlias))
|
||||
.has_value(),
|
||||
"bunq: a third fraction digit is refused rather than truncated");
|
||||
Check(!Server::ParseBunqMutation(
|
||||
payloadWith(R"({"currency":"EUR","value":"abc"})", kDonorAlias))
|
||||
.has_value(),
|
||||
"bunq: a non-numeric amount is refused");
|
||||
|
||||
const Server::FinancialRules rules = Server::LoadFinancialRules(
|
||||
R"({"donation_accounts":[9911],)"
|
||||
R"("rules":[)"
|
||||
|
|
@ -223,6 +303,31 @@ void BunqIngest() {
|
|||
Check(Server::ClassifyMutation(x, rules).group.empty(),
|
||||
"bunq: an unmatched mutation is withheld, not guessed");
|
||||
|
||||
// A payload with no "currency" key at all still has the shape the parser
|
||||
// needs, so it parses — and is then refused by the classifier, which is
|
||||
// where the euro-only rule lives. Nothing reaches a euro total on the
|
||||
// strength of a field that was never sent.
|
||||
const auto noCurrency =
|
||||
Server::ParseBunqMutation(payloadWith(R"({"value":"25.00"})", kDonorAlias));
|
||||
Check(noCurrency && noCurrency->amountMinor == 2500 && noCurrency->currency.empty(),
|
||||
"bunq: an amount with no currency still parses");
|
||||
Check(noCurrency && Server::ClassifyMutation(*noCurrency, rules).group.empty(),
|
||||
"bunq: an unstated currency is never assumed to be euro");
|
||||
|
||||
// bunq's other alias flavour nests the IBAN one level down, under
|
||||
// "labelMonetaryAccount". If that fallback broke, the IBAN would come
|
||||
// back empty, the owner's own transfer INTO the donation account would
|
||||
// stop matching its ignore rule, and the donation-account default would
|
||||
// publish the owner's own money as a stranger's gift — on the one page
|
||||
// whose entire promise is that the number is true.
|
||||
const auto nested = Server::ParseBunqMutation(payloadWith(
|
||||
R"({"currency":"EUR","value":"25.00"})",
|
||||
R"({"labelMonetaryAccount":{"iban":"NL01OWNSELF0000000","display_name":"Self"}})"));
|
||||
Check(nested && nested->counterpartyIban == "NL01OWNSELF0000000",
|
||||
"bunq: the nested alias flavour still yields an iban");
|
||||
Check(nested && Server::ClassifyMutation(*nested, rules).group == "ignore",
|
||||
"bunq: the owner's own transfer in is ignored, whichever alias shape carries it");
|
||||
|
||||
Server::BankMutation bill;
|
||||
bill.currency = "EUR";
|
||||
bill.amountMinor = -1200;
|
||||
|
|
@ -257,6 +362,109 @@ void BunqIngest() {
|
|||
Check(fin.donationCount == before.donationCount
|
||||
&& fin.ExpensesMinor() == before.ExpensesMinor(),
|
||||
"bunq: an unclassified mutation changes no total");
|
||||
|
||||
// A REFUNDED gift. Reachable because an explicit rule may name a group
|
||||
// outright, so "donations" is not the exclusive property of the
|
||||
// incoming-only account default tested above.
|
||||
const Server::FinancialRules donationRules = Server::LoadFinancialRules(
|
||||
R"({"rules":[{"iban":"NL55BUNQ2025123456","group":"donations"}]})");
|
||||
Server::BankMutation giftBack = *m;
|
||||
giftBack.amountMinor = -1000;
|
||||
const Server::MutationClass backClass =
|
||||
Server::ClassifyMutation(giftBack, donationRules);
|
||||
Check(backClass.group == "donations",
|
||||
"bunq: an explicit rule can classify outgoing money as a donation");
|
||||
// The count follows money IN, never money out: 2500 - 1000 = 1500, and
|
||||
// the one person who gave still gave. Decrementing here would put the
|
||||
// published donor count below the number of people who actually donated,
|
||||
// and the weekly reconciliation folds through this same function — it
|
||||
// would reproduce the wrong figure rather than correct it.
|
||||
Financials gifts;
|
||||
gifts.donationsMinor = 2500;
|
||||
gifts.donationCount = 1;
|
||||
Server::ApplyMutation(gifts, backClass, giftBack);
|
||||
Check(gifts.donationsMinor == 1500 && gifts.donationCount == 1,
|
||||
"bunq: a refunded gift reduces the total and leaves the count alone");
|
||||
|
||||
// Two expenses under different labels are two rows, in first-seen order.
|
||||
// Merging them would hide what the money went on behind one bigger
|
||||
// number, which is the opposite of what this page is for.
|
||||
Server::BankMutation supplier;
|
||||
supplier.currency = "EUR";
|
||||
supplier.amountMinor = -5000;
|
||||
supplier.counterpartyIban = "DE02SUPPLIER000000";
|
||||
supplier.created = "2026-08-16";
|
||||
const Server::MutationClass supplierClass = Server::ClassifyMutation(supplier, rules);
|
||||
Check(supplierClass.group == "expense" && supplierClass.label == "Inventory",
|
||||
"bunq: iban matching picks the supplier's category");
|
||||
Financials twoCats;
|
||||
Server::ApplyMutation(twoCats, billClass, bill); // -1200 out → +1200 Hosting
|
||||
Server::ApplyMutation(twoCats, supplierClass, supplier); // -5000 out → +5000 Inventory
|
||||
Check(twoCats.expenses.size() == 2
|
||||
&& twoCats.expenses[0].label == "Hosting"
|
||||
&& twoCats.expenses[0].totalMinor == 1200
|
||||
&& twoCats.expenses[1].label == "Inventory"
|
||||
&& twoCats.expenses[1].totalMinor == 5000,
|
||||
"bunq: distinct labels become distinct rows, in first-seen order");
|
||||
Check(twoCats.ExpensesMinor() == 6200, "bunq: the expense total is the sum of its rows");
|
||||
}
|
||||
|
||||
// ── the callback gate ─────────────────────────────────────────────────
|
||||
//
|
||||
// The one endpoint that writes public money figures, and the only thing
|
||||
// standing in front of it. Driven through ConfigureFinancials because that is
|
||||
// how the real server reaches it; no key material and no network are needed
|
||||
// to pin the parts that matter.
|
||||
void CallbackGate() {
|
||||
// Unconfigured: the path is a plain 404 and nothing authorises. An
|
||||
// endpoint that is off should not announce itself by answering
|
||||
// differently to a well-formed guess than to an empty one.
|
||||
Server::ConfigureFinancials(Server::FinancialsConfig{});
|
||||
Check(!Server::BunqCallbackConfigured(),
|
||||
"callback: with no secret the endpoint does not exist");
|
||||
Check(!Server::BunqCallbackAuthorised("", "{}", ""),
|
||||
"callback: an empty secret authorises nothing while unconfigured");
|
||||
Check(!Server::BunqCallbackAuthorised("s3cret-not-real", "{}", ""),
|
||||
"callback: even a well-formed secret is refused while unconfigured");
|
||||
|
||||
Server::FinancialsConfig cfg;
|
||||
cfg.callbackSecret = "s3cret-not-real"; // never a live one: the real
|
||||
// secret only ever comes from
|
||||
// the environment on the box
|
||||
Server::ConfigureFinancials(cfg);
|
||||
Check(Server::BunqCallbackAuthorised("s3cret-not-real", "{}", ""),
|
||||
"callback: the exact secret is authorised");
|
||||
// SecretEqual folds a length mismatch into the same accumulator as the
|
||||
// byte differences, so neither a prefix nor an extension can return early
|
||||
// — a plain == would leak the secret one byte at a time through timing,
|
||||
// and the secret sits in the URL where it can be probed a request at a
|
||||
// time.
|
||||
Check(!Server::BunqCallbackAuthorised("s3cret-not-rea", "{}", ""),
|
||||
"callback: a prefix of the secret is refused");
|
||||
Check(!Server::BunqCallbackAuthorised("s3cret-not-realX", "{}", ""),
|
||||
"callback: an extension of the secret is refused");
|
||||
Check(!Server::BunqCallbackAuthorised("", "{}", ""),
|
||||
"callback: an empty secret never matches a configured one");
|
||||
// A secret with nowhere to write the aggregates is still no endpoint:
|
||||
// this is what keeps the path a 404 on a box that has the env var but
|
||||
// not the storage.
|
||||
Check(!Server::BunqCallbackConfigured(),
|
||||
"callback: a secret without an aggregates path leaves the endpoint off");
|
||||
|
||||
cfg.publicPath = "/nonexistent-catcrafts/financials.json";
|
||||
Server::ConfigureFinancials(cfg);
|
||||
Check(Server::BunqCallbackConfigured(),
|
||||
"callback: secret plus aggregates path is what turns the endpoint on");
|
||||
|
||||
// Turning signature checking ON must never become a no-op. With a key
|
||||
// path that cannot be read there is no way to verify anything, so the
|
||||
// CORRECT secret now fails too — closed, not open.
|
||||
cfg.publicKeyPem = "/nonexistent-catcrafts/bunq-public-key.pem";
|
||||
Server::ConfigureFinancials(cfg);
|
||||
Check(!Server::BunqCallbackAuthorised("s3cret-not-real", "{}", "YWJj"),
|
||||
"callback: signature checking with an unreadable key fails closed");
|
||||
|
||||
Server::ConfigureFinancials(Server::FinancialsConfig{}); // leave no global behind
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
|
@ -264,6 +472,7 @@ void BunqIngest() {
|
|||
int main() {
|
||||
FinancialsPage();
|
||||
BunqIngest();
|
||||
CallbackGate();
|
||||
|
||||
if (failures != 0) {
|
||||
std::println(std::cerr, "{} check(s) failed", failures);
|
||||
|
|
|
|||
|
|
@ -21,15 +21,15 @@ int main(int argc, char** argv) {
|
|||
|
||||
// The price is rendered from the same integers the checkout charges, with
|
||||
// the derived ex-VAT twin alongside — asserting both pins the arithmetic.
|
||||
srv.BodyHas("/shop/fp6-pmos", "€563.30",
|
||||
"product page shows the from-price (green supplier + €50)");
|
||||
srv.BodyHas("/shop/fp6-pmos", "€465.54", "product page shows the derived ex-VAT price");
|
||||
srv.BodyHas("/shop/fp6-pmos", "€573.80",
|
||||
"product page shows the from-price (green supplier + €60.50 gross markup)");
|
||||
srv.BodyHas("/shop/fp6-pmos", "€474.21", "product page shows the derived ex-VAT price");
|
||||
srv.BodyHas("/shop/fp6-pmos", ">from<", "product page marks the price as a from-price");
|
||||
srv.BodyHas("/shop", "€563.30", "shop card shows the from-price");
|
||||
srv.BodyHas("/shop", "€573.80", "shop card shows the from-price");
|
||||
// Every colour is priced in the selector, and the form carries the exact
|
||||
// data blob the preview computes from.
|
||||
srv.BodyHas("/shop/fp6-pmos", "Black — €569.30", "colour selector prices black");
|
||||
srv.BodyHas("/shop/fp6-pmos", "White — €654.88", "colour selector prices white");
|
||||
srv.BodyHas("/shop/fp6-pmos", "Black — €579.80", "colour selector prices black");
|
||||
srv.BodyHas("/shop/fp6-pmos", "White — €665.38", "colour selector prices white");
|
||||
if (srv.ShopOpen()) {
|
||||
srv.BodyHas("/shop/fp6-pmos", "data-cc=", "form embeds the pricing blob");
|
||||
srv.BodyHas("/shop/fp6-pmos", "id=\"cc-total\"", "live total element present");
|
||||
|
|
@ -38,6 +38,29 @@ int main(int argc, char** argv) {
|
|||
srv.BodyHas("/shop", "coming soon", "shop card carries the coming-soon badge");
|
||||
srv.BodyLacks("/shop/fp6-pmos", "<form", "no order form while coming soon");
|
||||
}
|
||||
// The donation item: open while the phone above may not be — the shop's
|
||||
// soft opening. Its card quotes no price (there is none), its page is a
|
||||
// form asking an amount and, optionally, an email — never an address.
|
||||
srv.BodyHas("/shop", "/shop/donation", "shop grid lists the donation item");
|
||||
srv.BodyHas("/shop", "any amount", "donation card quotes no price");
|
||||
srv.BodyHas("/shop/donation", "name=\"amount\"", "donation form asks for an amount");
|
||||
srv.BodyHas("/shop/donation", "Donate — continue to payment",
|
||||
"donation form submits to payment");
|
||||
srv.BodyLacks("/shop/donation", "name=\"street\"",
|
||||
"donation form asks no address — nothing ships");
|
||||
srv.BodyLacks("/shop/donation", "name=\"quantity\"",
|
||||
"donation form has no quantity — one gift, one line");
|
||||
srv.BodyHas("/shop/donation", "No VAT is charged on a donation",
|
||||
"donation page states the VAT treatment");
|
||||
srv.BodyHas("/shop/donation", "aggregate total",
|
||||
"donation page states how it appears on the financials page");
|
||||
// Both rails are configured in this harness, so the donation offers the
|
||||
// same payment choice checkout does.
|
||||
srv.BodyHas("/shop/donation", "value=\"crypto\"", "donation form offers crypto");
|
||||
// No price, no conversions, no preview: the donation page ships NO
|
||||
// executable script at all — stricter than the shop pages' one-script rule.
|
||||
srv.BodyLacks("/shop/donation", "<script>", "donation page ships no executable script");
|
||||
|
||||
srv.BodyHas("/shop/fp6-pmos", "src=\"/fp6-pmos.jpg\"", "product page embeds the photo");
|
||||
srv.BodyHas("/shop", "src=\"/fp6-pmos.jpg\"", "shop card embeds the thumbnail");
|
||||
// The image file itself is Caddy's to serve (static asset), so its
|
||||
|
|
@ -56,7 +79,7 @@ int main(int argc, char** argv) {
|
|||
srv.BodyHas("/shop", "class=\"price__single\"", "shop card renders the single-number price");
|
||||
srv.BodyHas("/shop", "data-gbp=\"~£", "shop card carries a GBP conversion");
|
||||
srv.BodyHas("/shop", "data-sek=\"~kr ", "shop card carries an SEK conversion");
|
||||
srv.BodyHas("/shop", "data-world=\"€465.54\"", "shop card carries the euro export fallback");
|
||||
srv.BodyHas("/shop", "data-world=\"€474.21\"", "shop card carries the euro export fallback");
|
||||
srv.BodyLacks("/shop", "data-usd=", "no USD price for a country the shop refuses");
|
||||
srv.BodyLacks("/shop", "data-cad=", "no CAD price for a country the shop refuses");
|
||||
// The product page gets the same headline element, so a British visitor
|
||||
|
|
|
|||
|
|
@ -31,8 +31,11 @@ void CatalogueContract() {
|
|||
using namespace Catcrafts::Money;
|
||||
|
||||
const auto& products = Content::Products();
|
||||
Check(products.size() == 1, "content: one product");
|
||||
if (products.size() == 1) {
|
||||
// Two entries: the phone, and the donation item that soft-opens the shop.
|
||||
// The phone stays FIRST — it is the headline, and the suites below
|
||||
// address Products()[0] as the priced product.
|
||||
Check(products.size() == 2, "content: two products");
|
||||
if (products.size() == 2) {
|
||||
const Product& pr = products[0];
|
||||
Check(pr.slug == "fp6-pmos", "content: product slug");
|
||||
// Coming-soon is the pre-launch state; launch flips it to
|
||||
|
|
@ -40,17 +43,52 @@ void CatalogueContract() {
|
|||
Check(pr.Buyable() || pr.ComingSoon(),
|
||||
"content: product is buyable or deliberately coming soon");
|
||||
Check(pr.variants.size() == 3, "content: three colours");
|
||||
// Cost-plus pricing, derived in code: supplier + €50, exactly.
|
||||
Check(pr.FindVariant("green") && pr.FindVariant("green")->priceInclMinor == 56330,
|
||||
"content: green = 513.30 supplier + 50 markup");
|
||||
Check(pr.FindVariant("black") && pr.FindVariant("black")->priceInclMinor == 56930,
|
||||
"content: black = 519.30 supplier + 50 markup");
|
||||
Check(pr.FindVariant("white") && pr.FindVariant("white")->priceInclMinor == 65488,
|
||||
"content: white = 604.88 supplier + 50 markup");
|
||||
// Cost-plus pricing, derived in code: supplier + €60.50 gross markup,
|
||||
// exactly — the gross-up of the €50 Catcrafts keeps after VAT. The
|
||||
// margin identity itself (net of retail = net of supplier + 5000) is
|
||||
// asserted below; these pin the resulting stickers.
|
||||
Check(pr.FindVariant("green") && pr.FindVariant("green")->priceInclMinor == 57380,
|
||||
"content: green = 513.30 supplier + 60.50 gross markup");
|
||||
Check(pr.FindVariant("black") && pr.FindVariant("black")->priceInclMinor == 57980,
|
||||
"content: black = 519.30 supplier + 60.50 gross markup");
|
||||
Check(pr.FindVariant("white") && pr.FindVariant("white")->priceInclMinor == 66538,
|
||||
"content: white = 604.88 supplier + 60.50 gross markup");
|
||||
Check(pr.FindVariant("mauve") == nullptr, "content: unknown colour is null");
|
||||
Check(pr.priceInclMinor == 56330, "content: from-price is the cheapest variant");
|
||||
Check(pr.priceInclMinor == 57380, "content: from-price is the cheapest variant");
|
||||
// The pricing rule as the user states it: after shipping (a pass-
|
||||
// through) and VAT, every unit sold walks away with €50.00 — however
|
||||
// the supplier moves. Checked against the COMPILED catalogue, per
|
||||
// variant, in the same arithmetic the invoice and checkout use:
|
||||
// net(retail) - net(supplier) must be exactly 5000 minor. Shipping
|
||||
// has its own round-trip guarantee in ShouldComputeMoney, and
|
||||
// Mollie's per-transaction fee is the one accepted deviation.
|
||||
for (const auto& [slug, supplier] :
|
||||
std::initializer_list<std::pair<std::string_view, std::int64_t>>{
|
||||
{ "green", 51330 }, { "black", 51930 }, { "white", 60488 } }) {
|
||||
const Variant* v = pr.FindVariant(slug);
|
||||
Check(v && Money::NetFromGross(v->priceInclMinor)
|
||||
- Money::NetFromGross(supplier) == 5000,
|
||||
"content: variant nets the supplier price plus exactly €50", slug);
|
||||
}
|
||||
Check(pr.CheapestVariant() && pr.CheapestVariant()->slug == "green",
|
||||
"content: cheapest is green");
|
||||
// The boxed weight of one outgoing parcel. Not decoration: this
|
||||
// single integer picks the carrier's weight bracket, so it decides
|
||||
// the shipping cents added to every order AND the per-country
|
||||
// quantity ceiling. Regressed to 0 or off by an order of magnitude,
|
||||
// the shop quotes a rate the carrier does not honour on real parcels.
|
||||
Check(pr.shipWeightGrams == 700, "content: one boxed unit weighs 700 g");
|
||||
{
|
||||
// A ladder straddling that weight. 700 g fits both bands, and
|
||||
// RateFor takes the CHEAPEST band that can carry it — 895, not
|
||||
// the tighter-looking 5500 — while the ceiling comes off the
|
||||
// heaviest band: 10000 / 700 = 14 units to a parcel.
|
||||
const std::vector<ShipBracket> ladder{ { 2000, 895 }, { 10000, 5500 } };
|
||||
Check(RateFor(ladder, pr.shipWeightGrams) == 895,
|
||||
"content: the shipped weight lands in the cheap carrier bracket");
|
||||
Check(MaxUnitsFor(ladder, pr.shipWeightGrams) == 14,
|
||||
"content: and caps one parcel at fourteen units");
|
||||
}
|
||||
Check(pr.safetyNote.find("112") != std::string::npos
|
||||
&& pr.safetyNote.find("not yet verified") != std::string::npos,
|
||||
"content: emergency-calling safety warning present and honest");
|
||||
|
|
@ -114,6 +152,45 @@ void CatalogueContract() {
|
|||
&& bare.meta.jsonLd.find("\"productGroupID\"") != std::string::npos,
|
||||
"schema: and the rest of the record still parses");
|
||||
}
|
||||
|
||||
// The donation item: the shop's soft opening. Available (it is what
|
||||
// the shop is open FOR) while the phone stays coming-soon; buyer
|
||||
// names the amount, so no price, no variants, no weight — and the
|
||||
// Buyable() price check is waived for exactly this shape.
|
||||
const Product& don = products[1];
|
||||
Check(don.slug == "donation" && don.donation,
|
||||
"content: the second product is the donation item");
|
||||
Check(don.Buyable() && !don.ComingSoon(),
|
||||
"content: the donation item is on sale while the phone is not");
|
||||
Check(don.priceInclMinor == 0 && don.variants.empty()
|
||||
&& don.shipWeightGrams == 0,
|
||||
"content: a donation has no price, no colours and no parcel");
|
||||
Check(don.warranty.empty() && don.specs.empty(),
|
||||
"content: a donation carries no spec sheet and no warranty");
|
||||
// Its page: a donation form (amount + optional email), no price line,
|
||||
// no product JSON-LD — an offer with no amount is a claim shopping
|
||||
// crawlers can only misread — and no Fairphone sections.
|
||||
{
|
||||
const auto dp = Views::RenderProduct(don, Rates{});
|
||||
const std::string_view html = dp.main.View();
|
||||
Check(html.find("name=\"amount\"") != std::string_view::npos,
|
||||
"donation page: the form asks for an amount");
|
||||
Check(html.find("name=\"street\"") == std::string_view::npos
|
||||
&& html.find("name=\"country\"") == std::string_view::npos,
|
||||
"donation page: no address is asked for — nothing ships");
|
||||
Check(html.find("field__req") == std::string_view::npos
|
||||
|| html.find("Email <span") == std::string_view::npos,
|
||||
"donation page: email is not marked required");
|
||||
Check(html.find("price--product") == std::string_view::npos,
|
||||
"donation page: no price line for an unpriced item");
|
||||
Check(html.find("Specifications") == std::string_view::npos
|
||||
&& html.find("Warranty") == std::string_view::npos,
|
||||
"donation page: no spec or warranty section renders");
|
||||
Check(dp.meta.jsonLd.empty(),
|
||||
"donation page: no product JSON-LD is published");
|
||||
Check(!dp.meta.geoPriceHint,
|
||||
"donation page: no price-hint script — nothing to convert");
|
||||
}
|
||||
}
|
||||
Check(!Content::Projects().empty(), "content: projects present");
|
||||
Check(Content::LegalPages().size() == 3, "content: three legal pages");
|
||||
|
|
@ -205,11 +282,201 @@ void IdentityGraph() {
|
|||
"schema: founder and about name one Person node");
|
||||
}
|
||||
|
||||
// ── the sale gates, built by hand ─────────────────────────────────────
|
||||
// The shipped catalogue is one product in one status with three colours, so
|
||||
// asserting against it can only ever exercise one arm of each guard. These
|
||||
// products exist to reach the others.
|
||||
//
|
||||
// Buyable() is the ONLY server-side gate on POST /checkout, and it is a
|
||||
// conjunction: status AND a price. ComingSoon() is separately what decides
|
||||
// whether a visitor is told "the shop has not opened yet" or "temporarily
|
||||
// unavailable", so the two are pinned apart rather than as one either/or.
|
||||
void SaleGates() {
|
||||
Product priced;
|
||||
priced.status = "available";
|
||||
priced.priceInclMinor = 56330;
|
||||
Check(priced.Buyable() && !priced.ComingSoon(),
|
||||
"status: available with a price is the one buyable state");
|
||||
|
||||
// The half the catalogue can never exercise. An "available" product with
|
||||
// no price is what a from-price sync that failed to run leaves behind
|
||||
// (Content::Products derives priceInclMinor from CheapestVariant); if
|
||||
// this arm of the conjunction regressed, checkout would mint a real order
|
||||
// record and a live payment link for €0.
|
||||
Product unpriced;
|
||||
unpriced.status = "available";
|
||||
unpriced.priceInclMinor = 0;
|
||||
Check(!unpriced.Buyable(), "status: an unpriced product cannot be bought");
|
||||
|
||||
// The donation arm of the same conjunction: no price by definition, yet
|
||||
// buyable — and ONLY because the flag says the buyer names the amount.
|
||||
// The status half still gates it like anything else.
|
||||
Product gift;
|
||||
gift.status = "available";
|
||||
gift.donation = true;
|
||||
Check(gift.Buyable(), "status: an available donation needs no price");
|
||||
gift.status = "unavailable";
|
||||
Check(!gift.Buyable(), "status: a withdrawn donation is not buyable either");
|
||||
|
||||
Product withdrawn;
|
||||
withdrawn.status = "unavailable";
|
||||
withdrawn.priceInclMinor = 56330;
|
||||
Check(!withdrawn.Buyable() && !withdrawn.ComingSoon(),
|
||||
"status: unavailable is neither for sale nor coming soon");
|
||||
|
||||
Product soon;
|
||||
soon.status = "coming-soon";
|
||||
soon.priceInclMinor = 56330;
|
||||
Check(soon.ComingSoon() && !soon.Buyable(),
|
||||
"status: coming-soon publishes a price without opening orders");
|
||||
|
||||
// A product with no colours is a supported catalogue shape — priceInclMinor
|
||||
// is then simply the price. The null return is the only thing standing
|
||||
// between that shape and the two call sites that dereference the result
|
||||
// (the checkout form's default selection, and the checkout handler).
|
||||
Product plain;
|
||||
plain.priceInclMinor = 56330;
|
||||
Check(plain.CheapestVariant() == nullptr,
|
||||
"variants: no colours means no cheapest colour");
|
||||
Check(plain.FindVariant("green") == nullptr && plain.FindVariant("") == nullptr,
|
||||
"variants: nothing is ever found in an empty colour list");
|
||||
|
||||
// The loop compares with a strict <, so a tie keeps the listed order.
|
||||
// That is what stops the advertised "from" price and the form's default
|
||||
// selection from naming different colours when two cost the same.
|
||||
Product tied;
|
||||
tied.priceInclMinor = 56330;
|
||||
tied.variants = {
|
||||
{ "green", "Forest Green", 56330 },
|
||||
{ "black", "Black", 56330 },
|
||||
};
|
||||
Check(tied.CheapestVariant() && tied.CheapestVariant()->slug == "green",
|
||||
"variants: equally priced colours keep the listed order");
|
||||
}
|
||||
|
||||
// ── the product page's live-total blob ────────────────────────────────
|
||||
// RenderCheckoutForm renders only for a Buyable product, and the shipped
|
||||
// catalogue is coming-soon, so today nothing renders it: the two suites that
|
||||
// read this attribute both sit behind a ShopOpen() gate. Flip a copy to
|
||||
// "available" and read the markup here instead — the refusal lists in that
|
||||
// blob are what make the on-page total decline in exactly the places
|
||||
// checkout declines, and a page that quotes a total for a sanctioned or
|
||||
// no-sale destination invites an order that must then be refused.
|
||||
void CheckoutPreviewData() {
|
||||
if (Content::Products().empty()) return;
|
||||
Product pr = Content::Products()[0];
|
||||
pr.status = "available";
|
||||
Check(pr.Buyable(), "checkout: the flipped copy is buyable, so the form renders");
|
||||
|
||||
const std::vector<Money::ShipRates> feedTable{
|
||||
{ "NL", { { 2000, 895 } } },
|
||||
{ "DE", { { 2000, 995 } } },
|
||||
};
|
||||
const auto page = Views::RenderProduct(pr, Rates{}, feedTable, {}, {}, true);
|
||||
const std::string_view html = page.main.View();
|
||||
|
||||
// The blob is a JSON document inside an HTML attribute, so every quote
|
||||
// arrives escaped — matching the escaped form is matching what the
|
||||
// browser actually parses back out.
|
||||
Check(html.find(""x":["US","CA"]") != std::string_view::npos,
|
||||
"checkout: the preview carries the no-sale list verbatim");
|
||||
Check(html.find(""s":["RU","BY","KP"]")
|
||||
!= std::string_view::npos,
|
||||
"checkout: the preview carries the sanctions list verbatim");
|
||||
// The unit weight, which is what selects a bracket out of the carrier
|
||||
// table the same blob carries.
|
||||
Check(html.find(""g":700") != std::string_view::npos,
|
||||
"checkout: the preview knows one unit's shipping weight");
|
||||
// Per-colour unit prices: the preview multiplies these, so they are the
|
||||
// same integers the checkout charges or the two disagree on screen.
|
||||
Check(html.find(""green":57380") != std::string_view::npos
|
||||
&& html.find(""black":57980") != std::string_view::npos
|
||||
&& html.find(""white":66538") != std::string_view::npos,
|
||||
"checkout: every colour is priced in the preview blob");
|
||||
}
|
||||
|
||||
// ── which euro amount a localised price converts ──────────────────────
|
||||
// The headline price rides along as one pre-formatted attribute per
|
||||
// currency, and the basis differs by EU membership: a member's currency
|
||||
// converts the VAT-inclusive price the buyer pays, everyone else's converts
|
||||
// the ex-VAT export price. Invert that test and a Swedish visitor sees a
|
||||
// figure 21% below what their card is charged, or a British one 21% above
|
||||
// the export price — both while every existing assertion (which only checks
|
||||
// that the attributes exist) still passes.
|
||||
void LocalisedPriceBasis() {
|
||||
// €121.00 inclusive: net = 12100 * 10000 / 12100 = 10000 exactly, so the
|
||||
// two bases are a clean €121 and €100 with no rounding to reason about.
|
||||
Product pr;
|
||||
pr.slug = "basis-probe";
|
||||
pr.name = "Basis probe";
|
||||
pr.status = "coming-soon";
|
||||
pr.priceInclMinor = 12100;
|
||||
|
||||
// 1 EUR = 1 unit in both currencies, so the printed number can only
|
||||
// report which euro amount the conversion started from.
|
||||
Rates rates;
|
||||
rates.date = "2026-01-01";
|
||||
rates.microPerEur.emplace_back("SEK", 1'000'000);
|
||||
rates.microPerEur.emplace_back("GBP", 1'000'000);
|
||||
|
||||
const auto page = Views::RenderProduct(pr, rates);
|
||||
const std::string_view html = page.main.View();
|
||||
|
||||
// SE is an EU member: base 12100, converted whole and half-up ->
|
||||
// (12100 * 1e6 + 5e7) / 1e8 = 121.
|
||||
Check(html.find(R"(data-sek="~kr 121")") != std::string_view::npos,
|
||||
"price: an EU member's currency converts the VAT-inclusive price");
|
||||
// GB is not: base 10000 -> 100.
|
||||
Check(html.find(R"(data-gbp="~£100")") != std::string_view::npos,
|
||||
"price: a non-EU currency converts the ex-VAT export price");
|
||||
// The euro fallback that no-JS visitors and crawlers read is the export
|
||||
// price, in the same units the two above were derived from.
|
||||
Check(html.find(R"(data-world="€100")") != std::string_view::npos,
|
||||
"price: the export euro price is the world default");
|
||||
}
|
||||
|
||||
// ── the URLs this site advertises as its own ──────────────────────────
|
||||
// The sitemap is the list of URLs the site asks crawlers to index; the nav
|
||||
// is what a visitor clicks. Both are hand-maintained lists, so a renamed
|
||||
// legal slug or a retired path left behind advertises a 404 — or a 301 — as
|
||||
// canonical, silently and forever. Nothing else cross-checks them: the route
|
||||
// suite walks a literal list of its own rather than SitemapPaths().
|
||||
void AdvertisedUrls() {
|
||||
Views::SiteContent site;
|
||||
site.legal = Content::LegalPages();
|
||||
|
||||
for (const std::string_view path : SitemapPaths()) {
|
||||
const Route r = ParseRoute(path);
|
||||
Check(r.kind != RouteKind::NotFound,
|
||||
"sitemap: every advertised path resolves to a page", path);
|
||||
// A route carrying a canonical target is a redirect whatever it
|
||||
// renders, and asking a crawler to index a redirect is asking it to
|
||||
// index a non-canonical URL.
|
||||
Check(r.canonicalRedirect.empty(),
|
||||
"sitemap: no advertised path is itself a redirect", path);
|
||||
// Parsing only proves the URL is SHAPED like a legal page; the
|
||||
// dispatcher's lookup is what decides whether it renders one.
|
||||
if (r.kind == RouteKind::Legal) {
|
||||
Check(site.FindLegal(r.slug) != nullptr,
|
||||
"sitemap: every advertised legal slug names a real page", path);
|
||||
}
|
||||
}
|
||||
|
||||
for (const NavItem& item : NavItems()) {
|
||||
Check(ParseRoute(item.href).kind == item.kind,
|
||||
"nav: every nav entry parses to the route it claims", item.href);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
CatalogueContract();
|
||||
IdentityGraph();
|
||||
SaleGates();
|
||||
CheckoutPreviewData();
|
||||
LocalisedPriceBasis();
|
||||
AdvertisedUrls();
|
||||
|
||||
if (failures != 0) {
|
||||
std::println(std::cerr, "{} check(s) failed", failures);
|
||||
|
|
|
|||
|
|
@ -50,6 +50,29 @@ int main() {
|
|||
Check(parse("n=caf%C3%A9")->Get("n") == "café", "form: utf-8 percent-decoding");
|
||||
Check(parse("n=100%")->Get("n") == "100%", "form: malformed escape passes through");
|
||||
Check(parse("n=%zz")->Get("n") == "%zz", "form: non-hex escape passes through");
|
||||
|
||||
// A repeated field keeps BOTH pairs and Get answers with the first. Most
|
||||
// urlencoded parsers in the wild take the last, so this is pinned rather
|
||||
// than left to the header comment: every refusal in ValidateCheckout reads
|
||||
// its field through Get, and flipping this to "last wins" would silently
|
||||
// hand a second `country=` the final say over where a parcel may go.
|
||||
{
|
||||
auto dup = parse("country=NL&country=RU");
|
||||
Check(dup->Size() == 2, "form: a repeated field keeps both pairs");
|
||||
Check(dup->Get("country") == "NL", "form: duplicates resolve to the first");
|
||||
}
|
||||
|
||||
// Field NAMES are percent-decoded, not only values — %73 is 's', so
|
||||
// `web%73ite` is the field `website`. The honeypot below is found by its
|
||||
// decoded name and nothing else, so this is what makes the trap closed
|
||||
// against a bot that encodes the key it is trying to avoid.
|
||||
{
|
||||
auto encodedName = parse("web%73ite=spam");
|
||||
Check(encodedName->Has("website"), "form: a percent-encoded field name decodes");
|
||||
Check(encodedName->Get("website") == "spam",
|
||||
"form: the value still attaches to the decoded name");
|
||||
}
|
||||
|
||||
// A field name is not allowed to be empty — "=x" is malformed, not a field.
|
||||
Check(!parse("=x").has_value(), "form: empty field name rejected");
|
||||
// Oversized input must be refused outright rather than truncated: acting on
|
||||
|
|
@ -129,6 +152,18 @@ int main() {
|
|||
Check(pot.errors.size() == 1 && pot.errors[0].message.find("honeypot") == std::string::npos
|
||||
&& pot.errors[0].message.find("website") == std::string::npos,
|
||||
"checkout: honeypot failure does not name the trap");
|
||||
// The same trap with the trigger field's NAME percent-encoded, which is the
|
||||
// obvious way to try to slip past it. It still fails closed only because
|
||||
// ParseUrlEncoded decodes the key before Get looks it up, and the refusal
|
||||
// has to stay identical — a different answer for the encoded spelling would
|
||||
// itself tell a bot which spelling worked.
|
||||
{
|
||||
auto sneaky = validate(std::string(kGoodOrder) + "&web%73ite=http%3A%2F%2Fspam");
|
||||
Check(!sneaky.Ok(), "checkout: honeypot catches a percent-encoded field name");
|
||||
Check(sneaky.errors.size() == 1 && sneaky.errors[0].field.empty()
|
||||
&& sneaky.errors[0].message == "Submission rejected.",
|
||||
"checkout: the encoded-name trap gives the same single generic refusal");
|
||||
}
|
||||
|
||||
Check(!validate("email=a%40b.example&name=" + std::string(200, 'x')
|
||||
+ "&street=x&postal=1&city=y&country=NL").Ok(),
|
||||
|
|
@ -150,6 +185,45 @@ int main() {
|
|||
"checkout: past the technical ceiling rejected");
|
||||
Check(!validate(std::string(kGoodOrder) + "&quantity=two").Ok(),
|
||||
"checkout: non-numeric quantity rejected");
|
||||
|
||||
// "two" fails at the first character, which is the easy half. The hard half
|
||||
// is a valid numeric PREFIX: from_chars consumes what it can, reports
|
||||
// success, and leaves the leftovers to the caller — so the only thing
|
||||
// standing between "2x" and a two-unit charge is the check that parsing
|
||||
// reached the end of the field. Quantity multiplies the unit price into
|
||||
// what the buyer actually pays, so a partial parse is a billing bug.
|
||||
{
|
||||
auto trailing = validate(std::string(kGoodOrder) + "&quantity=2x");
|
||||
Check(!trailing.Ok(), "checkout: a numeric prefix with trailing junk rejected");
|
||||
Check(trailing.errors.size() == 1 && trailing.errors[0].field == "quantity",
|
||||
"checkout: the quantity refusal hangs off the quantity field");
|
||||
// Rejected means rejected, not "keep what we managed to read": the
|
||||
// parsed 2 must not survive into value, because value is what the
|
||||
// handler prices if anything upstream ever ignores Ok().
|
||||
Check(trailing.value.quantity == 1,
|
||||
"checkout: a rejected quantity resets to 1, not the parsed prefix");
|
||||
}
|
||||
// from_chars for an integer stops at 'e' and at '.', so left unchecked each
|
||||
// of these would be read as a bare 1 rather than refused — and "1e3" is a
|
||||
// spelling of 1000 that no form control produces.
|
||||
Check(!validate(std::string(kGoodOrder) + "&quantity=1e3").Ok(),
|
||||
"checkout: exponent notation rejected rather than partly read");
|
||||
Check(!validate(std::string(kGoodOrder) + "&quantity=1.5").Ok(),
|
||||
"checkout: a fractional quantity rejected rather than truncated");
|
||||
// "-1" parses cleanly all the way to the end, so it survives the prefix
|
||||
// check and is caught by the range floor instead.
|
||||
Check(!validate(std::string(kGoodOrder) + "&quantity=-1").Ok(),
|
||||
"checkout: a negative quantity rejected");
|
||||
Check(validate(std::string(kGoodOrder) + "&quantity=-1").value.quantity == 1,
|
||||
"checkout: a negative quantity never reaches the record");
|
||||
// Trim runs before from_chars, so surrounding whitespace is not junk:
|
||||
// "%20" decodes to a space and " 2" trims back to "2". A buyer who pastes
|
||||
// a padded number is not making a hostile submission.
|
||||
Check(validate(std::string(kGoodOrder) + "&quantity=%202").Ok(),
|
||||
"checkout: a leading space on the quantity is trimmed, not rejected");
|
||||
Check(validate(std::string(kGoodOrder) + "&quantity=%202").value.quantity == 2,
|
||||
"checkout: the trimmed quantity is the one that counts");
|
||||
|
||||
Check(!validate(std::string(kGoodOrder) + "&color=" + std::string(40, 'x')).Ok(),
|
||||
"checkout: oversized colour rejected");
|
||||
|
||||
|
|
@ -210,6 +284,21 @@ int main() {
|
|||
Check(ru.value.country == "RU", "checkout: sanctioned country echoed back");
|
||||
}
|
||||
|
||||
// Parameter pollution against the order gate. Every refusal above reads its
|
||||
// field through Fields::Get, which takes the FIRST of a repeated pair, so
|
||||
// appending a second value cannot reopen a destination the first one closed
|
||||
// — kGoodOrder already carries country=nl, and the trailing RU is inert.
|
||||
// The same property protects the number the buyer is charged for.
|
||||
{
|
||||
auto polluted = validate(std::string(kGoodOrder) + "&country=RU");
|
||||
Check(polluted.Ok(),
|
||||
"checkout: a trailing second country cannot displace the first");
|
||||
Check(polluted.value.country == "NL",
|
||||
"checkout: the first country is the one validated and stored");
|
||||
Check(validate(std::string(kGoodOrder) + "&quantity=2&quantity=99").value.quantity == 2,
|
||||
"checkout: a second quantity cannot raise what is charged");
|
||||
}
|
||||
|
||||
// The shipping refusals. These are templates rather than plain strings
|
||||
// because the buy page fills the same ones client-side, so the substitution
|
||||
// has to work on both {cc} and {n} — a template that silently kept its
|
||||
|
|
@ -249,6 +338,78 @@ int main() {
|
|||
Check(rejected.value.country == "NLD", "checkout: invalid country echoed back as typed");
|
||||
Check(rejected.value.name == "Ada", "checkout: valid sibling field preserved");
|
||||
|
||||
// ── the euro-amount parser ────────────────────────────────────────
|
||||
// Exact integer parsing for the one amount that ever arrives from a
|
||||
// client (the donation). Same no-floats rule as every money path.
|
||||
Check(ParseEuroAmountToMinor("25") == 2500, "amount: whole euros");
|
||||
Check(ParseEuroAmountToMinor("12.50") == 1250, "amount: euros and cents");
|
||||
Check(ParseEuroAmountToMinor("12,50") == 1250, "amount: comma decimal mark");
|
||||
Check(ParseEuroAmountToMinor("2.5") == 250, "amount: one decimal is tenths, not cents");
|
||||
Check(ParseEuroAmountToMinor("0.01") == 1, "amount: a single cent parses");
|
||||
Check(ParseEuroAmountToMinor("10000") == 1000000, "amount: the ceiling parses");
|
||||
Check(!ParseEuroAmountToMinor("").has_value(), "amount: empty rejected");
|
||||
Check(!ParseEuroAmountToMinor("-5").has_value(), "amount: negative rejected");
|
||||
Check(!ParseEuroAmountToMinor("1e3").has_value(), "amount: exponent rejected");
|
||||
Check(!ParseEuroAmountToMinor("1.234").has_value(), "amount: third decimal rejected");
|
||||
Check(!ParseEuroAmountToMinor("1.2.3").has_value(), "amount: two marks rejected");
|
||||
Check(!ParseEuroAmountToMinor(".50").has_value(), "amount: bare fraction rejected");
|
||||
Check(!ParseEuroAmountToMinor("25 EUR").has_value(), "amount: trailing text rejected");
|
||||
Check(!ParseEuroAmountToMinor("12345678901").has_value(), "amount: oversized rejected");
|
||||
|
||||
// ── donation validation ───────────────────────────────────────────
|
||||
// Its own validator, not checkout with fields waived: nothing ships, so
|
||||
// no address is even asked for, and email is optional — the order page's
|
||||
// capability URL is already the receipt.
|
||||
auto donate = [](std::string_view body) {
|
||||
return ValidateDonation(*ParseUrlEncoded(body));
|
||||
};
|
||||
|
||||
{
|
||||
auto ok = donate("amount=25");
|
||||
Check(ok.Ok(), "donation: an amount alone is a complete submission");
|
||||
Check(ok.value.amountMinor == 2500, "donation: the amount lands in cents");
|
||||
Check(ok.value.quantity == 1, "donation: quantity is always one");
|
||||
Check(ok.value.email.empty(), "donation: no email means no email");
|
||||
}
|
||||
Check(donate("amount=12.50&email=a%40b.example").Ok(),
|
||||
"donation: an email may ride along for the confirmation");
|
||||
Check(donate("amount=12.50&email=a%40b.example").value.amountMinor == 1250,
|
||||
"donation: cents survive alongside the email");
|
||||
Check(!donate("amount=25&email=nonsense").Ok(),
|
||||
"donation: a present-but-bad email is still refused");
|
||||
Check(!donate("email=a%40b.example").Ok(), "donation: no amount, no donation");
|
||||
Check(!donate("amount=nonsense").Ok(), "donation: an unparseable amount is refused");
|
||||
Check(!donate("amount=0.99").Ok(), "donation: below the €1 floor refused");
|
||||
Check(donate("amount=1").Ok(), "donation: the €1 floor itself is welcome");
|
||||
Check(donate("amount=10000").Ok(), "donation: the €10,000 ceiling itself is welcome");
|
||||
Check(!donate("amount=10000.01").Ok(), "donation: past the ceiling refused");
|
||||
{
|
||||
// Rejected means rejected: the out-of-range figure must not survive
|
||||
// into value, because value is what the handler charges if anything
|
||||
// upstream ever ignores Ok().
|
||||
auto big = donate("amount=99999");
|
||||
Check(big.value.amountMinor == 0,
|
||||
"donation: a refused amount never reaches the record");
|
||||
Check(big.errors.size() == 1 && big.errors[0].field == "amount",
|
||||
"donation: the refusal hangs off the amount field");
|
||||
}
|
||||
// The same honeypot as checkout, reported just as namelessly.
|
||||
{
|
||||
auto pot2 = donate("amount=25&website=spam");
|
||||
Check(!pot2.Ok(), "donation: honeypot rejects");
|
||||
Check(pot2.errors.size() == 1 && pot2.errors[0].field.empty()
|
||||
&& pot2.errors[0].message == "Submission rejected.",
|
||||
"donation: honeypot failure does not name the trap");
|
||||
}
|
||||
// The payment choice, same rules as checkout.
|
||||
Check(donate("amount=25&pay=crypto").value.payChoice == Catcrafts::Form::kPayCrypto,
|
||||
"donation: crypto choice parsed");
|
||||
Check(!donate("amount=25&pay=free").Ok(), "donation: unknown payment choice rejected");
|
||||
// First-wins duplicates protect the amount exactly as they protect
|
||||
// checkout's quantity: a trailing second value cannot raise the charge.
|
||||
Check(donate("amount=25&amount=9999").value.amountMinor == 2500,
|
||||
"donation: a second amount cannot displace the first");
|
||||
|
||||
if (failures != 0) {
|
||||
std::println(std::cerr, "{} check(s) failed", failures);
|
||||
return 1;
|
||||
|
|
|
|||
Loading…
Reference in a new issue