sanctions block list
All checks were successful
Deploy / build-deploy (push) Successful in 2m18s

This commit is contained in:
Jorijn van der Graaf 2026-08-15 03:22:25 +02:00
commit b0666841f6
7 changed files with 139 additions and 31 deletions

View file

@ -74,6 +74,15 @@ int main() {
"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");
// Sanctioned destinations refuse through the same gate, but the two lists
// stay distinguishable — the checkout error names the law for one and the
// shop's own choice for the other.
Check(!SellsTo("RU") && !SellsTo("BY") && !SellsTo("KP"),
"sanctions: RU, BY and KP refused");
Check(IsSanctioned("RU") && IsSanctioned("BY") && IsSanctioned("KP"),
"sanctions: the list knows its members");
Check(!IsSanctioned("US") && !IsSanctioned("NL"),
"sanctions: the insurance refusal is not a sanctions refusal");
// ── carrier weight brackets ───────────────────────────────────────
// The only shipping prices that exist. A ladder covering 2 kg / 10 kg /
@ -150,6 +159,10 @@ int main() {
Check(!CurrencyFor(cc).has_value(),
"fx: refused destinations have no display currency", cc);
}
for (const std::string_view cc : SanctionedCountries()) {
Check(!CurrencyFor(cc).has_value(),
"fx: sanctioned destinations have no display currency", cc);
}
// ── rates loader ──────────────────────────────────────────────────
const Rates r = LoadRates(

View file

@ -480,6 +480,15 @@ void AlwaysOnValidation(TestServer& srv) {
"email=ca%40example.org&name=Terry&street=1%20Bloor%20St&postal=M4W&city=Toronto&country=CA");
srv.CheckStatus("/shop/fp6-pmos", "422", "POST",
"email=us%40example.org&name=Pat&street=1%20Main%20St&postal=43004&city=Columbus&country=us");
// Sanctioned destinations (Money::SanctionedCountries) refuse through the
// same always-on gate — this refusal is the law, so of all the checks in
// this file it is the one that must survive every refactor.
srv.CheckStatus("/shop/fp6-pmos", "422", "POST",
"email=ru%40example.org&name=Sasha&street=1%20Tverskaya&postal=125009&city=Moscow&country=RU");
srv.CheckStatus("/shop/fp6-pmos", "422", "POST",
"email=by%40example.org&name=Vanya&street=1%20Kastrychnitskaya&postal=220030&city=Minsk&country=BY");
srv.CheckStatus("/shop/fp6-pmos", "422", "POST",
"email=ru%40example.org&name=Sasha&street=1%20Tverskaya&postal=125009&city=Moscow&country=ru");
// Refused in validation means nothing reached the ledger and no payment
// link was ever created.
Check(LedgerLines(srv).size() == before, "a refused destination creates no order record");
@ -514,13 +523,28 @@ void RejectedFormEcho(TestServer& srv) {
Check(refused.body.find("value=\"Pat\"") != std::string::npos,
"a refused submission keeps what was typed");
}
// A sanctioned destination gets the sanctions sentence, not the policy
// one — the buyer should learn the law forbids the sale, not wonder what
// insurance has to do with Moscow.
{
const auto refused = srv.Post("/shop/fp6-pmos",
"email=ru%40example.org&name=Sasha&street=1%20Tverskaya&postal=125009&city=Moscow&country=RU");
Check(refused.body.find("EU sanctions prohibit") != std::string::npos,
"sanctions refusal explains itself on the form");
Check(refused.body.find("field__error\">Catcrafts does not sell") == std::string::npos,
"sanctions refusal is not worded as the policy one");
}
// The buy panel warns before anyone fills it in, and the preview script
// carries the same list so it cannot quote a total the server would
// carries the same lists so it cannot quote a total the server would
// refuse.
srv.BodyHas("/shop/fp6-pmos", "does not sell or ship to the United States or Canada",
"buy panel states where the shop does not sell");
srv.BodyHas("/shop/fp6-pmos", "cannot sell or ship to Russia, Belarus or North Korea",
"buy panel states where the law forbids selling");
srv.BodyHas("/shop/fp6-pmos", ""x":["US","CA"]",
"total preview knows the refused destinations");
srv.BodyHas("/shop/fp6-pmos", ""s":["RU","BY","KP"]",
"total preview knows the sanctioned destinations");
// The honeypot message must not name the trap, or it teaches the next
// bot. Only the ERROR NOTICE is inspected: the re-rendered form
// legitimately contains the name="website" field itself — that IS the

View file

@ -193,6 +193,23 @@ int main() {
Check(us.value.country == "US", "checkout: refused country echoed back");
}
// 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");
}
// 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
@ -212,12 +229,16 @@ int main() {
"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");
// 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");
}
// A rejected field must still come back, or the visitor has to retype the