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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue