2026-08-15 00:54:05 +02:00
|
|
|
/*
|
|
|
|
|
catcrafts.net
|
|
|
|
|
Copyright (C) 2026 Catcrafts
|
|
|
|
|
|
|
|
|
|
The source code of this website is made available for viewing purposes only.
|
|
|
|
|
No permission is granted to copy, modify, distribute, or create derivative works.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
// The form layer: urlencoded parsing, the email/country shape checks, and
|
|
|
|
|
// checkout validation — the gate every order submission passes through.
|
|
|
|
|
|
|
|
|
|
import std;
|
|
|
|
|
import Catcrafts.Shared;
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
} // namespace
|
|
|
|
|
|
|
|
|
|
int main() {
|
|
|
|
|
using namespace Catcrafts::Form;
|
|
|
|
|
|
|
|
|
|
// ── urlencoded parsing ────────────────────────────────────────────
|
|
|
|
|
auto parse = [](std::string_view b) { return ParseUrlEncoded(b); };
|
|
|
|
|
|
|
|
|
|
auto f = parse("email=a%40b.example&country=NL");
|
|
|
|
|
Check(f.has_value(), "form: basic body parses");
|
|
|
|
|
if (f) {
|
|
|
|
|
Check(f->Get("email") == "a@b.example", "form: %40 decodes to @");
|
|
|
|
|
Check(f->Get("country") == "NL", "form: second field");
|
|
|
|
|
Check(f->Get("missing").empty(), "form: absent field is empty");
|
|
|
|
|
Check(!f->Has("missing"), "form: Has() distinguishes absent");
|
|
|
|
|
}
|
|
|
|
|
Check(parse("a=1&&b=2")->Size() == 2, "form: empty segment tolerated");
|
|
|
|
|
Check(parse("a=1&")->Size() == 1, "form: trailing & tolerated");
|
|
|
|
|
Check(parse("flag")->Has("flag"), "form: valueless key present");
|
|
|
|
|
Check(parse("")->Size() == 0, "form: empty body");
|
|
|
|
|
Check(parse("q=hello+world")->Get("q") == "hello world", "form: + is space");
|
|
|
|
|
Check(parse("q=a%2Bb")->Get("q") == "a+b", "form: %2B is a literal plus");
|
|
|
|
|
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");
|
2026-08-17 11:04:03 +02:00
|
|
|
|
|
|
|
|
// 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");
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-15 00:54:05 +02:00
|
|
|
// 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
|
|
|
|
|
// half a form is worse than refusing it.
|
|
|
|
|
Check(!parse(std::string(kMaxBodyBytes + 1, 'a')).has_value(), "form: oversized body rejected");
|
|
|
|
|
Check(!parse("a=" + std::string(kMaxFieldBytes + 1, 'x')).has_value(), "form: oversized field rejected");
|
|
|
|
|
|
|
|
|
|
// ── email shape ───────────────────────────────────────────────────
|
|
|
|
|
Check(LooksLikeEmail("a@b.example"), "email: minimal");
|
|
|
|
|
Check(LooksLikeEmail("first.last+tag@sub.domain.example"), "email: tagged, subdomain");
|
|
|
|
|
Check(!LooksLikeEmail("no-at-sign"), "email: no @");
|
|
|
|
|
Check(!LooksLikeEmail("@domain.example"), "email: empty local part");
|
|
|
|
|
Check(!LooksLikeEmail("user@"), "email: empty domain");
|
|
|
|
|
Check(!LooksLikeEmail("a@b@c.example"), "email: two @");
|
|
|
|
|
Check(!LooksLikeEmail("user@dotless"), "email: dotless domain");
|
|
|
|
|
Check(!LooksLikeEmail("user@.example"), "email: domain starts with dot");
|
|
|
|
|
Check(!LooksLikeEmail("a b@c.example"), "email: embedded space");
|
|
|
|
|
// Header-injection characters must never survive into anything that later
|
|
|
|
|
// builds an email envelope.
|
|
|
|
|
Check(!LooksLikeEmail("a@b.example\nBcc: x@y.example"), "email: newline rejected");
|
|
|
|
|
Check(!LooksLikeEmail("a@b.example\r\nSubject: x"), "email: CRLF rejected");
|
|
|
|
|
Check(!LooksLikeEmail("a,b@c.example"), "email: comma rejected");
|
|
|
|
|
Check(!LooksLikeEmail("<a@b.example>"), "email: angle brackets rejected");
|
|
|
|
|
Check(!LooksLikeEmail(std::string(250, 'a') + "@b.example"), "email: over 254 chars rejected");
|
|
|
|
|
|
|
|
|
|
// ── country code ──────────────────────────────────────────────────
|
|
|
|
|
Check(LooksLikeCountryCode("NL"), "country: uppercase");
|
|
|
|
|
Check(LooksLikeCountryCode("ca"), "country: lowercase accepted");
|
|
|
|
|
Check(!LooksLikeCountryCode("NLD"), "country: three letters rejected");
|
|
|
|
|
Check(!LooksLikeCountryCode("N"), "country: one letter rejected");
|
|
|
|
|
Check(!LooksLikeCountryCode("N1"), "country: digit rejected");
|
|
|
|
|
Check(!LooksLikeCountryCode(""), "country: empty rejected");
|
|
|
|
|
Check(Upper("nl") == "NL", "country: normalised to upper");
|
|
|
|
|
|
|
|
|
|
// ── trimming ──────────────────────────────────────────────────────
|
|
|
|
|
Check(Trim(" x ") == "x", "trim: spaces");
|
|
|
|
|
Check(Trim("\t\r\nx\n") == "x", "trim: tabs and newlines");
|
|
|
|
|
Check(Trim(" ").empty(), "trim: all whitespace");
|
|
|
|
|
|
|
|
|
|
// ── checkout validation ───────────────────────────────────────────
|
|
|
|
|
constexpr std::string_view kGoodOrder =
|
|
|
|
|
"email=a%40b.example&name=Ada&street=Main%20St%201&postal=1234AB&city=Delft&country=nl";
|
|
|
|
|
|
|
|
|
|
auto validate = [](std::string_view body) {
|
|
|
|
|
return ValidateCheckout(*ParseUrlEncoded(body));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
auto good = validate(kGoodOrder);
|
|
|
|
|
Check(good.Ok(), "checkout: valid submission accepted");
|
|
|
|
|
Check(good.value.country == "NL", "checkout: country uppercased");
|
|
|
|
|
Check(good.value.street == "Main St 1", "checkout: street decoded and kept");
|
|
|
|
|
|
|
|
|
|
Check(!validate("name=Ada&street=x&postal=1&city=y&country=NL").Ok(),
|
|
|
|
|
"checkout: missing email rejected");
|
|
|
|
|
Check(!validate("email=a%40b.example&street=x&postal=1&city=y&country=NL").Ok(),
|
|
|
|
|
"checkout: missing name rejected");
|
|
|
|
|
Check(!validate("email=a%40b.example&name=Ada&postal=1&city=y&country=NL").Ok(),
|
|
|
|
|
"checkout: missing street rejected");
|
|
|
|
|
Check(!validate("email=a%40b.example&name=Ada&street=x&city=y&country=NL").Ok(),
|
|
|
|
|
"checkout: missing postal rejected");
|
|
|
|
|
Check(!validate("email=a%40b.example&name=Ada&street=x&postal=1&country=NL").Ok(),
|
|
|
|
|
"checkout: missing city rejected");
|
|
|
|
|
Check(!validate("email=a%40b.example&name=Ada&street=x&postal=1&city=y").Ok(),
|
|
|
|
|
"checkout: missing country rejected");
|
|
|
|
|
Check(!validate("email=nonsense&name=Ada&street=x&postal=1&city=y&country=NL").Ok(),
|
|
|
|
|
"checkout: bad email rejected");
|
|
|
|
|
|
|
|
|
|
// Every problem is reported at once — a form that surfaces one error per
|
|
|
|
|
// submission makes people resubmit to discover the rest.
|
|
|
|
|
Check(validate("email=&name=&street=&postal=&city=&country=").errors.size() == 6,
|
|
|
|
|
"checkout: errors accumulate");
|
|
|
|
|
|
|
|
|
|
// Honeypot: a filled hidden field means a bot. The message must not name
|
|
|
|
|
// the trap, or it teaches the next one how to pass.
|
|
|
|
|
auto pot = validate(std::string(kGoodOrder) + "&website=http%3A%2F%2Fspam");
|
|
|
|
|
Check(!pot.Ok(), "checkout: honeypot rejects");
|
|
|
|
|
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");
|
2026-08-17 11:04:03 +02:00
|
|
|
// 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");
|
|
|
|
|
}
|
2026-08-15 00:54:05 +02:00
|
|
|
|
|
|
|
|
Check(!validate("email=a%40b.example&name=" + std::string(200, 'x')
|
|
|
|
|
+ "&street=x&postal=1&city=y&country=NL").Ok(),
|
|
|
|
|
"checkout: overlong name rejected");
|
|
|
|
|
|
|
|
|
|
// Colour and quantity: shape checks here, catalogue checks in the handler.
|
|
|
|
|
Check(validate(std::string(kGoodOrder) + "&color=green&quantity=2").Ok(),
|
|
|
|
|
"checkout: colour and quantity accepted");
|
|
|
|
|
Check(validate(std::string(kGoodOrder) + "&quantity=2").value.quantity == 2,
|
|
|
|
|
"checkout: quantity parsed");
|
|
|
|
|
Check(validate(kGoodOrder).value.quantity == 1, "checkout: quantity defaults to 1");
|
|
|
|
|
Check(!validate(std::string(kGoodOrder) + "&quantity=0").Ok(),
|
|
|
|
|
"checkout: zero quantity rejected");
|
|
|
|
|
Check(validate(std::string(kGoodOrder) + "&quantity=9").Ok(),
|
|
|
|
|
"checkout: bulk quantity welcome");
|
|
|
|
|
Check(validate(std::string(kGoodOrder) + "&quantity=99").Ok(),
|
|
|
|
|
"checkout: the technical ceiling itself is fine");
|
|
|
|
|
Check(!validate(std::string(kGoodOrder) + "&quantity=100").Ok(),
|
|
|
|
|
"checkout: past the technical ceiling rejected");
|
|
|
|
|
Check(!validate(std::string(kGoodOrder) + "&quantity=two").Ok(),
|
|
|
|
|
"checkout: non-numeric quantity rejected");
|
2026-08-17 11:04:03 +02:00
|
|
|
|
|
|
|
|
// "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");
|
|
|
|
|
|
2026-08-15 00:54:05 +02:00
|
|
|
Check(!validate(std::string(kGoodOrder) + "&color=" + std::string(40, 'x')).Ok(),
|
|
|
|
|
"checkout: oversized colour rejected");
|
|
|
|
|
|
|
|
|
|
// The payment choice. Absent is a form that offered none (one rail
|
|
|
|
|
// configured, or the no-JS fallback page) and the handler resolves it to
|
|
|
|
|
// bank — the validator's job is only to refuse a word it does not know
|
|
|
|
|
// rather than let it fall through to a default the buyer never picked.
|
|
|
|
|
Check(validate(kGoodOrder).value.payChoice.empty(),
|
|
|
|
|
"checkout: absent payment choice stays empty");
|
|
|
|
|
Check(validate(std::string(kGoodOrder) + "&pay=bank").value.payChoice
|
|
|
|
|
== Catcrafts::Form::kPayBank,
|
|
|
|
|
"checkout: bank choice parsed");
|
|
|
|
|
Check(validate(std::string(kGoodOrder) + "&pay=crypto").value.payChoice
|
|
|
|
|
== Catcrafts::Form::kPayCrypto,
|
|
|
|
|
"checkout: crypto choice parsed");
|
|
|
|
|
{
|
|
|
|
|
auto bogus = validate(std::string(kGoodOrder) + "&pay=free");
|
|
|
|
|
Check(!bogus.Ok(), "checkout: unknown payment choice rejected");
|
|
|
|
|
Check(bogus.errors.size() == 1 && bogus.errors[0].field == "pay",
|
|
|
|
|
"checkout: the payment refusal hangs off the payment field");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Destinations the shop refuses. Well-formed, real country codes — the
|
|
|
|
|
// refusal is policy, so it has to survive every spelling the form accepts,
|
|
|
|
|
// and it must not spill onto other non-EU destinations.
|
|
|
|
|
auto withCountry = [&](std::string_view cc) {
|
|
|
|
|
return validate("email=a%40b.example&name=Ada&street=x&postal=1&city=y&country="
|
|
|
|
|
+ std::string(cc));
|
|
|
|
|
};
|
|
|
|
|
Check(!withCountry("US").Ok(), "checkout: US refused");
|
|
|
|
|
Check(!withCountry("CA").Ok(), "checkout: CA refused");
|
|
|
|
|
Check(!withCountry("us").Ok(), "checkout: lowercase US refused too");
|
|
|
|
|
Check(withCountry("GB").Ok(), "checkout: other non-EU destinations still sell");
|
|
|
|
|
Check(withCountry("NL").Ok(), "checkout: EU unaffected");
|
|
|
|
|
{
|
|
|
|
|
auto us = withCountry("US");
|
|
|
|
|
Check(us.errors.size() == 1 && us.errors[0].field == "country",
|
|
|
|
|
"checkout: refusal is a country error, nothing else");
|
|
|
|
|
Check(us.errors[0].message == Catcrafts::Form::kNoSaleMessage,
|
|
|
|
|
"checkout: refusal says where the shop does not sell");
|
|
|
|
|
Check(us.value.country == "US", "checkout: refused country echoed back");
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-15 03:22:25 +02:00
|
|
|
// Sanctioned destinations: same gate, different sentence. The message has
|
|
|
|
|
// to name the law rather than shop policy — a buyer told "Catcrafts does
|
|
|
|
|
// not sell to Russia" would reasonably email to ask; one told the EU
|
|
|
|
|
// forbids it knows nothing can be arranged.
|
|
|
|
|
Check(!withCountry("RU").Ok(), "checkout: RU refused");
|
|
|
|
|
Check(!withCountry("BY").Ok(), "checkout: BY refused");
|
|
|
|
|
Check(!withCountry("KP").Ok(), "checkout: KP refused");
|
|
|
|
|
Check(!withCountry("ru").Ok(), "checkout: lowercase RU refused too");
|
|
|
|
|
{
|
|
|
|
|
auto ru = withCountry("RU");
|
|
|
|
|
Check(ru.errors.size() == 1 && ru.errors[0].field == "country",
|
|
|
|
|
"checkout: sanctions refusal is a country error, nothing else");
|
|
|
|
|
Check(ru.errors[0].message == Catcrafts::Form::kSanctionsMessage,
|
|
|
|
|
"checkout: sanctions refusal names the law, not shop policy");
|
|
|
|
|
Check(ru.value.country == "RU", "checkout: sanctioned country echoed back");
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 11:04:03 +02:00
|
|
|
// 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");
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-15 00:54:05 +02:00
|
|
|
// 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
|
|
|
|
|
// placeholder would ship "up to {n} per order" to a real buyer.
|
|
|
|
|
{
|
|
|
|
|
const std::string none = NoShippingMessage("BR");
|
|
|
|
|
Check(none.find("BR") != std::string::npos
|
|
|
|
|
&& none.find("{cc}") == std::string::npos,
|
|
|
|
|
"shipping copy: the uncovered-country message names the country");
|
|
|
|
|
const std::string heavy = TooHeavyMessage("JP", 3);
|
|
|
|
|
Check(heavy.find("JP") != std::string::npos && heavy.find("3") != std::string::npos
|
|
|
|
|
&& heavy.find("{n}") == std::string::npos,
|
|
|
|
|
"shipping copy: the too-heavy message names the country and the limit");
|
|
|
|
|
const std::string nofit = TooHeavyMessage("JP", 0);
|
|
|
|
|
Check(nofit.find("JP") != std::string::npos
|
|
|
|
|
&& nofit.find("up to") == std::string::npos,
|
|
|
|
|
"shipping copy: with nothing fitting it does not promise a quantity");
|
|
|
|
|
Check(FillShipMessage("{cc} {n} {cc}", "NL", 2) == "NL 2 NL",
|
|
|
|
|
"shipping copy: every placeholder is filled, not just the first");
|
2026-08-15 03:22:25 +02:00
|
|
|
// Only the splittable refusal offers a way to order anyway — that
|
|
|
|
|
// buyer's order works as several smaller ones. The other two are
|
|
|
|
|
// final: what the carrier can't take, the shop doesn't ship, and a
|
|
|
|
|
// refusal that invites hand-arranging would promise exactly the
|
|
|
|
|
// ad-hoc export the shop decided against.
|
|
|
|
|
Check(heavy.find("orders@catcrafts.net") != std::string::npos,
|
|
|
|
|
"shipping copy: the splittable refusal names a human to email");
|
|
|
|
|
Check(none.find("orders@catcrafts.net") == std::string::npos
|
|
|
|
|
&& nofit.find("orders@catcrafts.net") == std::string::npos,
|
|
|
|
|
"shipping copy: unshippable refusals are final, no workaround offered");
|
2026-08-15 00:54:05 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// A rejected field must still come back, or the visitor has to retype the
|
|
|
|
|
// one thing they got wrong — the fastest way to lose a submission.
|
|
|
|
|
auto rejected = validate("email=notanemail&name=Ada&street=Main%201&postal=1&city=y&country=NLD");
|
|
|
|
|
Check(!rejected.Ok(), "checkout: invalid pair rejected");
|
|
|
|
|
Check(rejected.value.email == "notanemail", "checkout: invalid email echoed back");
|
|
|
|
|
Check(rejected.value.country == "NLD", "checkout: invalid country echoed back as typed");
|
|
|
|
|
Check(rejected.value.name == "Ada", "checkout: valid sibling field preserved");
|
|
|
|
|
|
2026-08-17 11:04:03 +02:00
|
|
|
// ── 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");
|
|
|
|
|
|
2026-08-15 00:54:05 +02:00
|
|
|
if (failures != 0) {
|
|
|
|
|
std::println(std::cerr, "{} check(s) failed", failures);
|
|
|
|
|
return 1;
|
|
|
|
|
}
|
|
|
|
|
return 0;
|
|
|
|
|
}
|