This commit is contained in:
parent
284f8d3e49
commit
70668af8f5
20 changed files with 2354 additions and 1048 deletions
|
|
@ -9,7 +9,7 @@ No permission is granted to copy, modify, distribute, or create derivative works
|
|||
// catcrafts-server — the native product.
|
||||
//
|
||||
// Serves the server-rendered pages (crawlers and no-JS clients get real HTML),
|
||||
// runs the shop — orders, the bunq payment rail, the reconciler — and doubles
|
||||
// runs the shop — orders, the payment rails, the reconciler — and doubles
|
||||
// as the test harness for Catcrafts.Shared.
|
||||
//
|
||||
// The harness half is not filler. Catcrafts.Shared is the security boundary
|
||||
|
|
@ -704,6 +704,74 @@ void RunFormSelfTest() {
|
|||
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.
|
||||
{
|
||||
using namespace Catcrafts::Form;
|
||||
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");
|
||||
|
|
@ -746,12 +814,49 @@ void RunMoneySelfTest() {
|
|||
Check(!IsEuCountry("nl"), "eu: lowercase is not a member (normalise first)");
|
||||
Check(ZoneFor("NL") == Zone::Nl, "zone: home");
|
||||
Check(ZoneFor("DE") == Zone::Eu, "zone: eu");
|
||||
Check(ZoneFor("CA") == Zone::World, "zone: world");
|
||||
Check(ZoneFor("GB") == Zone::World, "zone: world");
|
||||
|
||||
// ── destinations the shop refuses ─────────────────────────────────
|
||||
// Zones still classify US and CA (the arithmetic is destination-blind, and
|
||||
// keeping it that way means one policy switch, not two); the sale is what
|
||||
// stops, in SellsTo.
|
||||
Check(!SellsTo("US") && !SellsTo("CA"), "policy: north america refused");
|
||||
Check(SellsTo("NL") && SellsTo("DE"), "policy: EU sells");
|
||||
Check(SellsTo("GB") && SellsTo("CH") && SellsTo("AU"),
|
||||
"policy: the rest of the world still sells");
|
||||
Check(SellsTo("us"), "policy: matched on the normalised code, like membership");
|
||||
Check(ZoneFor("US") == Zone::World, "zone: refused countries still classify");
|
||||
|
||||
// ── carrier weight brackets ───────────────────────────────────────
|
||||
// The only shipping prices that exist. A ladder covering 2 kg / 10 kg /
|
||||
// 20 kg, with the 20 kg band deliberately CHEAPER than the 10 kg one —
|
||||
// real carrier tariffs do that, and picking the tightest band rather than
|
||||
// the cheapest one that carries the parcel would overcharge for it.
|
||||
{
|
||||
const std::vector<ShipBracket> ladder{ { 2000, 895 }, { 10000, 1650 },
|
||||
{ 20000, 1490 } };
|
||||
Check(RateFor(ladder, 700) == 895, "brackets: one unit takes the 2 kg band");
|
||||
Check(RateFor(ladder, 2000) == 895, "brackets: the ceiling is inclusive");
|
||||
Check(RateFor(ladder, 2001) == 1490,
|
||||
"brackets: cheapest band that CARRIES it, not the tightest");
|
||||
Check(RateFor(ladder, 20001) == 0, "brackets: above every band is no price");
|
||||
Check(RateFor({}, 700) == 0, "brackets: an uncovered country has no price");
|
||||
|
||||
Check(MaxUnitsFor(ladder, 700) == 28, "brackets: units that fit one parcel");
|
||||
Check(MaxUnitsFor(ladder, 25000) == 0,
|
||||
"brackets: a unit heavier than every band fits nothing");
|
||||
Check(MaxUnitsFor(ladder, 0) == 0, "brackets: no weight, no answer");
|
||||
Check(MaxUnitsFor({}, 700) == 0, "brackets: no ladder, nothing fits");
|
||||
|
||||
// The table-level lookups the handler and the page both go through.
|
||||
const std::vector<ShipRates> table{ { "NL", ladder }, { "JP", { { 2000, 4250 } } } };
|
||||
Check(RateFor(LadderFor(table, "NL"), 700) == 895, "table: NL priced");
|
||||
Check(RateFor(LadderFor(table, "JP"), 2100) == 0,
|
||||
"table: JP has one light band, so two units are unshippable");
|
||||
Check(LadderFor(table, "BR").empty(), "table: unlisted country is empty");
|
||||
}
|
||||
|
||||
// ── order totals ──────────────────────────────────────────────────
|
||||
Check(ZoneShipping(1500, 2500, 5500, "NL") == 1500, "ship: NL zone");
|
||||
Check(ZoneShipping(1500, 2500, 5500, "DE") == 2500, "ship: EU zone");
|
||||
Check(ZoneShipping(1500, 2500, 5500, "CA") == 5500, "ship: world zone");
|
||||
|
||||
// NL: gross + shipping, VAT included in both.
|
||||
auto nl = ComputeTotals(58000, 1, 1500, "NL");
|
||||
|
|
@ -765,17 +870,17 @@ void RunMoneySelfTest() {
|
|||
"totals: EU");
|
||||
|
||||
// Export: net goods, world shipping, no VAT.
|
||||
auto ca = ComputeTotals(58000, 1, 5500, "CA");
|
||||
Check(ca.goods == 47934 && ca.shipping == 5500 && ca.total == 53434,
|
||||
auto gb = ComputeTotals(58000, 1, 5500, "GB");
|
||||
Check(gb.goods == 47934 && gb.shipping == 5500 && gb.total == 53434,
|
||||
"totals: export");
|
||||
Check(!ca.vatIncluded && ca.vatCharged == 0, "totals: export carries no VAT");
|
||||
Check(!gb.vatIncluded && gb.vatCharged == 0, "totals: export carries no VAT");
|
||||
|
||||
// Quantity: the export net is derived from the LINE total, not per unit —
|
||||
// per-unit rounding times qty would differ by a cent here, and the JS
|
||||
// preview mirrors this exact formula.
|
||||
auto ca2 = ComputeTotals(57500, 2, 5500, "CA");
|
||||
Check(ca2.goods == NetFromGross(115000), "totals: qty nets the line, not the unit");
|
||||
Check(ca2.goods == 95041, "totals: 2× green export net exact");
|
||||
auto gb2 = ComputeTotals(57500, 2, 5500, "GB");
|
||||
Check(gb2.goods == NetFromGross(115000), "totals: qty nets the line, not the unit");
|
||||
Check(gb2.goods == 95041, "totals: 2× green export net exact");
|
||||
auto nl2 = ComputeTotals(57500, 3, 1500, "NL");
|
||||
Check(nl2.goods == 172500 && nl2.total == 174000, "totals: qty multiplies gross");
|
||||
|
||||
|
|
@ -814,7 +919,17 @@ void RunMoneySelfTest() {
|
|||
// with its ONE offer — built from the same integers the
|
||||
// checkout charges.
|
||||
{
|
||||
auto pp = Views::RenderProduct(pr, Rates{});
|
||||
// The listing's shipping block is now carrier data, so the
|
||||
// render needs a table. US is priced here on purpose: the
|
||||
// carrier will happily quote it and the shop still must not
|
||||
// advertise it.
|
||||
const std::vector<ShipRates> feedTable{
|
||||
{ "NL", { { 2000, 895 } } },
|
||||
{ "DE", { { 2000, 995 } } },
|
||||
{ "GB", { { 2000, 2450 } } },
|
||||
{ "US", { { 2000, 1794 } } },
|
||||
};
|
||||
auto pp = Views::RenderProduct(pr, Rates{}, feedTable);
|
||||
auto ld = Json::Parse(pp.meta.jsonLd);
|
||||
bool variantsOk = false;
|
||||
if (ld && ld->IsObject()) {
|
||||
|
|
@ -838,6 +953,24 @@ void RunMoneySelfTest() {
|
|||
&& pp.meta.jsonLd.find("\"sku\"") != std::string::npos
|
||||
&& pp.meta.jsonLd.find("\"productGroupID\"") != std::string::npos,
|
||||
"schema: variants carry shipping, returns, sku and group id");
|
||||
// The published rates ARE the carrier's, at one unit's weight.
|
||||
Check(pp.meta.jsonLd.find("\"8.95\"") != std::string::npos
|
||||
&& pp.meta.jsonLd.find("\"24.50\"") != std::string::npos,
|
||||
"schema: shipping rates come from the carrier table");
|
||||
Check(pp.meta.jsonLd.find("\"17.94\"") == std::string::npos
|
||||
&& pp.meta.jsonLd.find("\"US\"") == std::string::npos,
|
||||
"schema: a refused destination is never advertised, priced or not");
|
||||
|
||||
// No table: no shipping claim. The listing loses the merchant
|
||||
// block rather than inventing a rate — the whole point of
|
||||
// dropping the zone fallback.
|
||||
auto bare = Views::RenderProduct(pr, Rates{});
|
||||
Check(bare.meta.jsonLd.find("OfferShippingDetails") == std::string::npos
|
||||
&& bare.meta.jsonLd.find("MerchantReturnPolicy") == std::string::npos,
|
||||
"schema: with no carrier table the offer publishes no shipping");
|
||||
Check(Json::Parse(bare.meta.jsonLd).has_value()
|
||||
&& bare.meta.jsonLd.find("\"productGroupID\"") != std::string::npos,
|
||||
"schema: and the rest of the record still parses");
|
||||
}
|
||||
}
|
||||
Check(!Content::Projects().empty(), "content: projects present");
|
||||
|
|
@ -931,51 +1064,92 @@ void RunMoneySelfTest() {
|
|||
}
|
||||
|
||||
// ── the Sendcloud response parser ─────────────────────────────────
|
||||
// Weights are the kilogram strings the API sends; every Find() below asks
|
||||
// for a parcel weight, because a price without a weight is not a thing this
|
||||
// table has any more.
|
||||
{
|
||||
const auto table = Server::ParseSendcloudMethods(R"({"shipping_methods":[
|
||||
{"name":"Other Method","countries":[{"iso_2":"NL","price":1.00}]},
|
||||
{"name":"DHL For You Home","countries":[
|
||||
{"name":"Other Method","min_weight":"0.001","max_weight":"10.000",
|
||||
"countries":[{"iso_2":"NL","price":1.00}]},
|
||||
{"name":"DHL For You Home","min_weight":"0.001","max_weight":"2.000",
|
||||
"countries":[
|
||||
{"iso_2":"NL","price":6.25},
|
||||
{"iso_2":"DE","price":8.20},
|
||||
{"iso_2":"CA","price":42.50},
|
||||
{"iso_2":"XX","price":0},
|
||||
{"iso_2":"TOOLONG","price":5.00}]}]})", "DHL For You");
|
||||
{"iso_2":"TOOLONG","price":5.00}]},
|
||||
{"name":"DHL For You Home","min_weight":"2.000","max_weight":"10.000",
|
||||
"countries":[{"iso_2":"NL","price":9.95},{"iso_2":"DE","price":13.40}]},
|
||||
{"name":"DHL For You Home","countries":[{"iso_2":"BE","price":1.00}]}]})",
|
||||
"DHL For You");
|
||||
Check(table.method == "DHL For You Home", "sendcloud: method matched by substring");
|
||||
Check(table.Find("NL") == 625, "sendcloud: NL price to cents");
|
||||
Check(table.Find("DE") == 820, "sendcloud: 8.20 rounds exactly");
|
||||
Check(table.Find("CA") == 4250, "sendcloud: CA price");
|
||||
Check(table.Find("XX") == 0, "sendcloud: zero price dropped");
|
||||
Check(table.Find("TOOLONG") == 0, "sendcloud: malformed iso dropped");
|
||||
Check(table.Find("NL", 700) == 625, "sendcloud: NL price to cents");
|
||||
Check(table.Find("DE", 700) == 820, "sendcloud: 8.20 rounds exactly");
|
||||
Check(table.Find("CA", 700) == 4250, "sendcloud: CA price");
|
||||
Check(table.Find("XX", 700) == 0, "sendcloud: zero price dropped");
|
||||
Check(table.Find("TOOLONG", 700) == 0, "sendcloud: malformed iso dropped");
|
||||
// The bug the old parser had: it stopped at the FIRST matching method,
|
||||
// so every parcel was priced at whichever band came first and the
|
||||
// heavier bands were invisible.
|
||||
Check(table.Find("NL", 2100) == 995 && table.Find("DE", 2100) == 1340,
|
||||
"sendcloud: every weight band of a matched method is kept");
|
||||
Check(table.Find("NL", 11000) == 0,
|
||||
"sendcloud: past the heaviest band there is no price");
|
||||
Check(table.Find("BE", 700) == 0,
|
||||
"sendcloud: a method with no weight range is unusable, not unlimited");
|
||||
Check(Server::ParseSendcloudMethods("garbage", "x").perCountry.empty(),
|
||||
"sendcloud: malformed payload yields nothing");
|
||||
|
||||
// Comma-separated merge: courier for Europe, post for the world; the
|
||||
// earlier method keeps any country both cover.
|
||||
// earlier FILTER keeps any country both cover — including that
|
||||
// country's heavier bands, which must not leak in from the later one.
|
||||
const auto merged = Server::ParseSendcloudMethods(R"({"shipping_methods":[
|
||||
{"name":"DPD Home","countries":[
|
||||
{"name":"DPD Home","min_weight":"0.001","max_weight":"10.000","countries":[
|
||||
{"iso_2":"NL","price":7.13},{"iso_2":"DE","price":10.49}]},
|
||||
{"name":"PostNL Parcels non-EU","countries":[
|
||||
{"name":"PostNL Parcels non-EU","min_weight":"0.001","max_weight":"2.000",
|
||||
"countries":[
|
||||
{"iso_2":"CA","price":23.95},{"iso_2":"US","price":17.94},
|
||||
{"iso_2":"DE","price":99.99}]}]})",
|
||||
{"iso_2":"DE","price":99.99}]},
|
||||
{"name":"PostNL Parcels non-EU","min_weight":"2.000","max_weight":"20.000",
|
||||
"countries":[{"iso_2":"CA","price":48.10},{"iso_2":"DE","price":99.99}]}]})",
|
||||
"DPD Home, PostNL Parcels non-EU");
|
||||
Check(merged.Find("NL") == 713 && merged.Find("CA") == 2395,
|
||||
"sendcloud: merged table covers both methods");
|
||||
Check(merged.Find("DE") == 1049,
|
||||
"sendcloud: earlier method wins a shared country");
|
||||
Check(merged.Find("NL", 700) == 713 && merged.Find("CA", 700) == 2395,
|
||||
"sendcloud: merged table covers both filters");
|
||||
Check(merged.Find("CA", 5000) == 4810, "sendcloud: heavier band from the later filter");
|
||||
Check(merged.Find("DE", 700) == 1049,
|
||||
"sendcloud: earlier filter wins a shared country");
|
||||
Check(merged.Find("DE", 12000) == 0,
|
||||
"sendcloud: and owns it outright — no band from the loser");
|
||||
Check(merged.method == "DPD Home + PostNL Parcels non-EU",
|
||||
"sendcloud: merged method names recorded");
|
||||
"sendcloud: merged method names recorded, deduplicated per band");
|
||||
|
||||
// Two services under one filter publishing the same ceiling: the
|
||||
// cheaper is the only sensible quote, since both carry the parcel.
|
||||
const auto dup = Server::ParseSendcloudMethods(R"({"shipping_methods":[
|
||||
{"name":"DPD Home","min_weight":"0.001","max_weight":"10.000",
|
||||
"countries":[{"iso_2":"NL","price":9.00}]},
|
||||
{"name":"DPD Home Signed","min_weight":"0.001","max_weight":"10.000",
|
||||
"countries":[{"iso_2":"NL","price":7.50}]}]})", "DPD Home");
|
||||
Check(dup.Find("NL", 700) == 750, "sendcloud: duplicate band keeps the cheaper");
|
||||
}
|
||||
|
||||
// ── 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");
|
||||
auto ca$ = CurrencyFor("CA");
|
||||
Check(ca$.has_value() && ca$->code == "CAD", "fx: CA -> CAD");
|
||||
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");
|
||||
Check(!CurrencyFor("XX").has_value(), "fx: unknown country has no conversion");
|
||||
if (ca$) {
|
||||
Check(FormatIndicative(*ca$, 920) == "≈ CA$920", "fx: display form");
|
||||
if (gbp) {
|
||||
Check(FormatIndicative(*gbp, 920) == "≈ £920", "fx: display form");
|
||||
}
|
||||
// A country the shop refuses gets no localised price either — the two
|
||||
// tables are kept consistent on purpose, so this is a real invariant and
|
||||
// not a coincidence of the current list.
|
||||
for (const std::string_view cc : NoSaleCountries()) {
|
||||
Check(!CurrencyFor(cc).has_value(),
|
||||
"fx: refused destinations have no display currency", cc);
|
||||
}
|
||||
|
||||
// ── order tokens and references ───────────────────────────────────
|
||||
|
|
@ -989,7 +1163,7 @@ void RunMoneySelfTest() {
|
|||
Check(Server::ReferenceFromToken("abcdef0123456789abcdef0123456789") == "CC-ABCDEF",
|
||||
"reference: derived and uppercased");
|
||||
|
||||
// ── the wire-amount parser (bunq responses) ───────────────────────
|
||||
// ── the wire-amount parser (both providers quote strings) ─────────
|
||||
using Server::ParseAmountToMinor;
|
||||
Check(ParseAmountToMinor("614.00") == 61400, "amount: normal");
|
||||
Check(ParseAmountToMinor("614") == 61400, "amount: no fraction");
|
||||
|
|
@ -1033,6 +1207,98 @@ void RunMoneySelfTest() {
|
|||
"mollie: missing id rejected");
|
||||
}
|
||||
|
||||
// ── the CoinGate order parser ─────────────────────────────────────
|
||||
//
|
||||
// The id is a JSON NUMBER at CoinGate, which is the one shape difference
|
||||
// from Mollie that could silently produce an empty payment id — an order
|
||||
// that can never be polled. Both spellings are pinned here.
|
||||
{
|
||||
const auto c1 = Server::ParseCoingateOrder(R"({
|
||||
"id":538,"status":"new","title":"CC-ABCDEF catcrafts.net",
|
||||
"price_amount":"578.30","price_currency":"EUR","receive_currency":"EUR",
|
||||
"payment_url":"https://pay.coingate.com/invoice/abc-123"})");
|
||||
Check(c1.has_value(), "coingate: new order parses");
|
||||
if (c1) {
|
||||
Check(c1->id == "538", "coingate: numeric id becomes decimal text");
|
||||
Check(c1->status == "new", "coingate: status");
|
||||
Check(c1->priceMinor == 57830, "coingate: price to cents");
|
||||
Check(c1->payUrl == "https://pay.coingate.com/invoice/abc-123",
|
||||
"coingate: payment url");
|
||||
Check(c1->payCurrency.empty(), "coingate: no coin picked yet");
|
||||
}
|
||||
const auto c2 = Server::ParseCoingateOrder(R"({
|
||||
"id":"539","status":"paid","pay_currency":"BTC",
|
||||
"price_amount":"578.3","price_currency":"EUR"})");
|
||||
Check(c2 && c2->id == "539", "coingate: string id also accepted");
|
||||
Check(c2 && c2->status == "paid" && c2->payCurrency == "BTC",
|
||||
"coingate: paid order carries the coin");
|
||||
Check(c2 && c2->priceMinor == 57830,
|
||||
"coingate: one-decimal amount is still cents");
|
||||
const auto c3 = Server::ParseCoingateOrder(R"({
|
||||
"id":540,"status":"paid","price_amount":"578.30","price_currency":"USD"})");
|
||||
Check(c3 && c3->priceMinor == 0, "coingate: non-EUR amount refuses to count");
|
||||
Check(!Server::ParseCoingateOrder("garbage").has_value(),
|
||||
"coingate: malformed payload rejected");
|
||||
Check(!Server::ParseCoingateOrder(R"({"status":"new"})").has_value(),
|
||||
"coingate: missing id rejected");
|
||||
Check(!Server::ParseCoingateOrder(R"({"id":541})").has_value(),
|
||||
"coingate: missing status rejected");
|
||||
}
|
||||
|
||||
// ── request provenance ────────────────────────────────────────────
|
||||
//
|
||||
// The rate limiter keys on this, so getting the WRONG end of the header
|
||||
// is not a cosmetic bug: the leftmost entry is client-controlled, and
|
||||
// trusting it would hand every attacker an endless supply of identities.
|
||||
{
|
||||
using Server::ClientAddressFromForwarded;
|
||||
Check(ClientAddressFromForwarded("203.0.113.7") == "203.0.113.7",
|
||||
"forwarded: single entry");
|
||||
Check(ClientAddressFromForwarded("198.51.100.4, 203.0.113.7") == "203.0.113.7",
|
||||
"forwarded: rightmost entry wins");
|
||||
// The attack this exists to defeat: a client that sends its own header
|
||||
// to look like a different peer. Caddy appends the truth on the right.
|
||||
Check(ClientAddressFromForwarded("1.1.1.1, 2.2.2.2, 203.0.113.7") == "203.0.113.7",
|
||||
"forwarded: spoofed prefix ignored");
|
||||
Check(ClientAddressFromForwarded("198.51.100.4, 203.0.113.7") == "203.0.113.7",
|
||||
"forwarded: padding trimmed");
|
||||
Check(ClientAddressFromForwarded("2001:db8::1") == "2001:db8::1",
|
||||
"forwarded: ipv6 passes through");
|
||||
Check(ClientAddressFromForwarded("").empty(), "forwarded: empty stays empty");
|
||||
// No header at all means nothing proxied this request; the caller must
|
||||
// see an empty peer and fall back to the global budget.
|
||||
Check(ClientAddressFromForwarded("198.51.100.4, ").empty(),
|
||||
"forwarded: empty last entry is no peer");
|
||||
}
|
||||
|
||||
{
|
||||
using Server::OriginAllowed;
|
||||
Check(OriginAllowed("https://catcrafts.net", "https://catcrafts.net"),
|
||||
"origin: same origin allowed");
|
||||
Check(OriginAllowed("https://catcrafts.net", "https://catcrafts.net/"),
|
||||
"origin: trailing slash on the base normalised");
|
||||
// A non-browser client (curl, the e2e suite) sends no Origin and
|
||||
// cannot be a cross-site forgery — there is no session to ride on.
|
||||
Check(OriginAllowed("", "https://catcrafts.net"), "origin: absent allowed");
|
||||
Check(!OriginAllowed("https://evil.example", "https://catcrafts.net"),
|
||||
"origin: foreign origin refused");
|
||||
// Neither a subdomain nor a lookalike is us.
|
||||
Check(!OriginAllowed("https://catcrafts.net.evil.example", "https://catcrafts.net"),
|
||||
"origin: suffix lookalike refused");
|
||||
Check(!OriginAllowed("https://shop.catcrafts.net", "https://catcrafts.net"),
|
||||
"origin: subdomain refused");
|
||||
// Scheme is part of an origin: http is not https.
|
||||
Check(!OriginAllowed("http://catcrafts.net", "https://catcrafts.net"),
|
||||
"origin: scheme mismatch refused");
|
||||
// A sandboxed iframe posts Origin: null. Present, and not us.
|
||||
Check(!OriginAllowed("null", "https://catcrafts.net"), "origin: null refused");
|
||||
Check(!OriginAllowed("https://catcrafts.net", ""),
|
||||
"origin: unconfigured base refuses rather than accepts all");
|
||||
// dev.sh serves on localhost and sets --redirect-base to match.
|
||||
Check(OriginAllowed("http://localhost:8080", "http://localhost:8080"),
|
||||
"origin: dev localhost base matches");
|
||||
}
|
||||
|
||||
// ── the invoice builder ───────────────────────────────────────────
|
||||
{
|
||||
Server::OrderRecord o;
|
||||
|
|
@ -1071,7 +1337,7 @@ void RunMoneySelfTest() {
|
|||
Check(eu.find("zero-rated") == std::string::npos, "invoice: EU is not an export");
|
||||
|
||||
o.vatIncluded = false;
|
||||
o.buyer.country = "CA";
|
||||
o.buyer.country = "GB";
|
||||
o.goodsMinor = 93107;
|
||||
o.shippingMinor = 2395;
|
||||
o.totalMinor = 95502;
|
||||
|
|
@ -1123,7 +1389,7 @@ void RunMoneySelfTest() {
|
|||
|
||||
// The export wording mirrors the invoice's VAT treatment.
|
||||
o.vatIncluded = false;
|
||||
o.buyer.country = "CA";
|
||||
o.buyer.country = "GB";
|
||||
o.totalMinor = 95502;
|
||||
const std::string exMail = Server::BuildOrderConfirmationEmail(
|
||||
o, "Fairphone 6", "Forest Green", "Catcrafts <info@catcrafts.net>",
|
||||
|
|
@ -1305,19 +1571,20 @@ int main(int argc, char** argv) {
|
|||
// /var/lib/catcrafts, which is deliberately NOT the web root — that
|
||||
// directory is publicly served and wiped by rsync --delete each deploy.
|
||||
std::filesystem::path ordersPath = "orders.jsonl";
|
||||
// Payment rail selection. Flags beat environment beats default. The
|
||||
// default is "whichever provider has a key, off otherwise" so a box
|
||||
// with no credentials serves the whole site minus checkout instead of
|
||||
// refusing to start. Mollie outranks bunq: bunq.me's per-method limits
|
||||
// (€500/card, nothing for non-EU buyers) disqualified it as the
|
||||
// checkout; the client is kept for a possible future account sweep.
|
||||
// Payment rail selection, one slot per payment choice the buyer gets.
|
||||
// Flags beat environment beats default, and the default for each slot
|
||||
// is "the provider whose key is set, off otherwise" — so a box with no
|
||||
// credentials serves the whole site minus checkout instead of refusing
|
||||
// to start, and a box with only one key offers only that one method.
|
||||
//
|
||||
// bank MOLLIE_API_KEY iDEAL, cards, transfer
|
||||
// crypto COINGATE_API_KEY on-chain and Lightning, settled to EUR
|
||||
const char* mollieKey = std::getenv("MOLLIE_API_KEY");
|
||||
const char* bunqKey = std::getenv("BUNQ_API_KEY");
|
||||
std::string railMode = mollieKey && *mollieKey ? "mollie"
|
||||
: bunqKey && *bunqKey ? "bunq"
|
||||
: "off";
|
||||
bool bunqSandbox = [] {
|
||||
const char* v = std::getenv("BUNQ_SANDBOX");
|
||||
const char* coingateKey = std::getenv("COINGATE_API_KEY");
|
||||
std::string railMode = mollieKey && *mollieKey ? "mollie" : "off";
|
||||
std::string cryptoMode = coingateKey && *coingateKey ? "coingate" : "off";
|
||||
bool coingateSandbox = [] {
|
||||
const char* v = std::getenv("COINGATE_SANDBOX");
|
||||
return v && std::string_view(v) == "1";
|
||||
}();
|
||||
std::filesystem::path railState;
|
||||
|
|
@ -1336,12 +1603,10 @@ int main(int argc, char** argv) {
|
|||
ordersPath = a.substr(9);
|
||||
} else if (a.starts_with("--rail=")) {
|
||||
railMode = a.substr(7);
|
||||
} else if (a.starts_with("--bunq=")) {
|
||||
railMode = a.substr(7); // legacy alias for --rail=
|
||||
} else if (a.starts_with("--crypto-rail=")) {
|
||||
cryptoMode = a.substr(14);
|
||||
} else if (a.starts_with("--rail-state=")) {
|
||||
railState = a.substr(13);
|
||||
} else if (a.starts_with("--bunq-state=")) {
|
||||
railState = a.substr(13); // legacy alias for --rail-state=
|
||||
} else if (a.starts_with("--redirect-base=")) {
|
||||
redirectBase = a.substr(16);
|
||||
} else {
|
||||
|
|
@ -1400,30 +1665,51 @@ int main(int argc, char** argv) {
|
|||
return 2;
|
||||
}
|
||||
|
||||
// The rail. State (bunq session context, or the fake rail's paid
|
||||
// marker; Mollie needs none) defaults next to the orders file — same
|
||||
// directory, same lifecycle, same backup.
|
||||
// The rails. State (only the fake rail has any — its paid marker;
|
||||
// neither real provider needs a session or a keypair) defaults next to
|
||||
// the orders file: same directory, same lifecycle, same backup.
|
||||
if (railState.empty()) {
|
||||
railState = ordersPath;
|
||||
railState += (railMode == "fake") ? ".fake-paid" : ".bunq-state.json";
|
||||
railState += ".fake-paid";
|
||||
}
|
||||
Server::RailConfig railCfg;
|
||||
railCfg.mode = railMode;
|
||||
railCfg.apiKey = railMode == "mollie" ? (mollieKey ? mollieKey : "")
|
||||
: railMode == "bunq" ? (bunqKey ? bunqKey : "")
|
||||
: "";
|
||||
railCfg.sandbox = bunqSandbox;
|
||||
railCfg.statePath = railState;
|
||||
railCfg.redirectBase = redirectBase;
|
||||
std::unique_ptr<Server::PaymentRail> rail = Server::MakeRail(railCfg);
|
||||
if ((railMode == "mollie" || railMode == "bunq") && railCfg.apiKey.empty()) {
|
||||
std::println(std::cerr,
|
||||
"catcrafts-server: --rail={} but its API key env is not set — "
|
||||
"refusing to start with a rail that cannot work", railMode);
|
||||
// A mode whose credential is missing is a misconfiguration, not a
|
||||
// reason to quietly serve a checkout that 502s at the last step. Both
|
||||
// slots are checked the same way, and both name the env var they want.
|
||||
auto build = [&](const std::string& mode, const char* key, const char* keyName,
|
||||
bool sandbox, std::unique_ptr<Server::PaymentRail>& out) -> bool {
|
||||
Server::RailConfig cfg;
|
||||
cfg.mode = mode;
|
||||
cfg.apiKey = key ? key : "";
|
||||
cfg.sandbox = sandbox;
|
||||
cfg.statePath = railState;
|
||||
cfg.redirectBase = redirectBase;
|
||||
const bool needsKey = mode == "mollie" || mode == "coingate";
|
||||
if (needsKey && cfg.apiKey.empty()) {
|
||||
std::println(std::cerr,
|
||||
"catcrafts-server: rail '{}' selected but {} is not set — "
|
||||
"refusing to start with a rail that cannot work",
|
||||
mode, keyName);
|
||||
return false;
|
||||
}
|
||||
out = Server::MakeRail(cfg);
|
||||
// "off" is a legitimate choice and yields no rail; a mode nobody
|
||||
// recognises silently would too, which is how a typo becomes a
|
||||
// shop that quietly stops taking one kind of money.
|
||||
if (!out && mode != "off") {
|
||||
std::println(std::cerr, "catcrafts-server: unknown rail '{}'", mode);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
Server::PaymentRails rails;
|
||||
if (!build(railMode, mollieKey, "MOLLIE_API_KEY", false, rails.bank)) return 2;
|
||||
if (!build(cryptoMode, coingateKey, "COINGATE_API_KEY", coingateSandbox,
|
||||
rails.crypto)) {
|
||||
return 2;
|
||||
}
|
||||
|
||||
Server::ConfigurePayments(std::move(rail), redirectBase);
|
||||
Server::ConfigurePayments(std::move(rails), redirectBase);
|
||||
|
||||
// Invoice signing: the GPG key uid/fingerprint; GNUPGHOME decides the
|
||||
// keyring. Unset means unsigned dev invoices with a visible marker.
|
||||
|
|
@ -1442,9 +1728,11 @@ int main(int argc, char** argv) {
|
|||
Server::ConfigureMail(std::move(mailCfg));
|
||||
}
|
||||
|
||||
// Sendcloud is optional: without credentials the compiled-in zone
|
||||
// table prices all shipping, which is exactly how dev and e2e run.
|
||||
// With credentials the refresh thread fetches per-country rates.
|
||||
// Sendcloud is the ONLY source of shipping prices: no credentials and
|
||||
// no cached table means checkout refuses every order (loudly logged at
|
||||
// startup). Dev and e2e get a table by writing the cache file next to
|
||||
// the orders file by hand — same format the refresh writes, so no test
|
||||
// hook exists for this and none can drift from production.
|
||||
Server::ShippingConfig shipCfg;
|
||||
if (const char* v = std::getenv("SENDCLOUD_PUBLIC_KEY")) shipCfg.publicKey = v;
|
||||
if (const char* v = std::getenv("SENDCLOUD_SECRET_KEY")) shipCfg.secretKey = v;
|
||||
|
|
@ -1457,7 +1745,7 @@ int main(int argc, char** argv) {
|
|||
}
|
||||
|
||||
// --orders [FILE]: the ledger, human-shaped. And the manual transitions —
|
||||
// the escape hatch for a payment bunq confirmed out-of-band (or a refund):
|
||||
// the escape hatch for a payment confirmed out-of-band (or a refund):
|
||||
// --orders FILE --mark-paid TOKEN | --mark-shipped TOKEN | --cancel TOKEN
|
||||
if (!args.empty() && args[0] == "--orders") {
|
||||
std::filesystem::path file = "orders.jsonl";
|
||||
|
|
@ -1499,14 +1787,21 @@ int main(int argc, char** argv) {
|
|||
std::println("orders: {}", orders.size());
|
||||
if (orders.empty()) return 0;
|
||||
std::println("");
|
||||
std::println("{:<10} {:<17} {:>10} {:<3} {:<8} {:>3} {:<11} {:<20} {}",
|
||||
"reference", "status", "total", "cc", "colour", "qty", "via",
|
||||
"created", "token");
|
||||
// `pay` is the rail the order was created on, `via` what actually
|
||||
// settled it. Both, because they answer different questions: an order
|
||||
// stuck awaiting needs the first (which provider's dashboard to open),
|
||||
// and a paid one needs the second (whether the money can still be
|
||||
// pulled back — cards can, iDEAL and crypto cannot).
|
||||
std::println("{:<10} {:<17} {:>10} {:<3} {:<8} {:>3} {:<7} {:<11} {:<20} {}",
|
||||
"reference", "status", "total", "cc", "colour", "qty", "pay",
|
||||
"via", "created", "token");
|
||||
for (const auto& o : orders) {
|
||||
std::println("{:<10} {:<17} {:>10} {:<3} {:<8} {:>3} {:<11} {:<20} {}",
|
||||
std::println("{:<10} {:<17} {:>10} {:<3} {:<8} {:>3} {:<7} {:<11} {:<20} {}",
|
||||
o.reference, o.status, Money::FormatMinor(o.totalMinor),
|
||||
o.buyer.country, o.color.empty() ? "-" : o.color,
|
||||
o.quantity, o.paidVia.empty() ? "-" : o.paidVia,
|
||||
o.quantity,
|
||||
o.payChoice.empty() ? "-" : o.payChoice,
|
||||
o.paidVia.empty() ? "-" : o.paidVia,
|
||||
o.createdAt, o.token);
|
||||
}
|
||||
return 0;
|
||||
|
|
@ -1514,10 +1809,12 @@ int main(int argc, char** argv) {
|
|||
|
||||
std::println("catcrafts-server: --selftest | --render <path> | --routes | --sitemap | --feed\n"
|
||||
" --serve [port] [--content=DIR] [--webroot=DIR] [--orders=FILE]\n"
|
||||
" [--rail=off|fake|mollie|bunq] [--rail-state=FILE] [--redirect-base=URL]\n"
|
||||
" [--rail=off|fake|mollie] [--crypto-rail=off|fake-crypto|coingate]\n"
|
||||
" [--rail-state=FILE] [--redirect-base=URL]\n"
|
||||
" --orders [FILE] [--mark-paid TOKEN | --mark-shipped TOKEN | --cancel TOKEN]\n"
|
||||
"\n"
|
||||
"environment: MOLLIE_API_KEY (test_… or live_…), BUNQ_API_KEY, BUNQ_SANDBOX=1,\n"
|
||||
"environment: MOLLIE_API_KEY (test_… or live_…) selects the bank rail,\n"
|
||||
" COINGATE_API_KEY the crypto rail, COINGATE_SANDBOX=1,\n"
|
||||
" ORDER_REDIRECT_BASE, SENDCLOUD_PUBLIC_KEY/SECRET_KEY/METHOD,\n"
|
||||
" INVOICE_GPG_KEY, MAIL_COMMAND (e.g. 'msmtp -t'), MAIL_FROM");
|
||||
return 0;
|
||||
|
|
|
|||
Loading…
Reference in a new issue