catcrafts.net/tests/ShouldValidateForms/main.cpp

236 lines
13 KiB
C++
Raw Normal View History

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");
// 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");
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");
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");
}
// 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");
// Both messages must offer the way out, since the shop is refusing
// business it would otherwise take.
Check(none.find("orders@catcrafts.net") != std::string::npos
&& heavy.find("orders@catcrafts.net") != std::string::npos
&& nofit.find("orders@catcrafts.net") != std::string::npos,
"shipping copy: every refusal names a human to email");
}
// 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");
if (failures != 0) {
std::println(std::cerr, "{} check(s) failed", failures);
return 1;
}
return 0;
}