All checks were successful
Deploy / build-deploy (push) Successful in 4m11s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
335 lines
18 KiB
C++
335 lines
18 KiB
C++
/*
|
||
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 invoice builder and the order confirmation email — the two documents a
|
||
// paying customer actually receives, including the VAT treatment on each and
|
||
// the header-injection guard on the address that goes into the envelope.
|
||
|
||
import std;
|
||
import Catcrafts.Shared;
|
||
import Catcrafts.Server;
|
||
|
||
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);
|
||
}
|
||
|
||
// 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() {
|
||
Server::OrderRecord o;
|
||
o.token = "0123456789abcdef0123456789abcdef";
|
||
o.reference = "CC-TEST01";
|
||
o.invoiceNumber = "f57c6512-f012-4b91-adb3-077876480178-7";
|
||
o.invoicedAt = "2026-08-05T10:00:00Z";
|
||
o.createdAt = "2026-08-05T09:55:00Z";
|
||
o.paidVia = "ideal";
|
||
o.buyer = { "b@example.org", "Ada Lovelace", "Main St 1", "1234AB",
|
||
"Delft", "NL" };
|
||
o.quantity = 2;
|
||
o.unitMinor = 56330;
|
||
o.goodsMinor = 112660;
|
||
o.shippingMinor = 863;
|
||
o.totalMinor = 113523;
|
||
o.vatIncluded = true;
|
||
|
||
const std::string eu = Server::BuildInvoiceMarkdown(o, "Fairphone 6", "Forest Green");
|
||
Check(eu.find("# Invoice f57c6512-f012-4b91-adb3-077876480178-7") != std::string::npos,
|
||
"invoice: number heading");
|
||
Check(eu.find("* Customer number: f57c6512-f012-4b91-adb3-077876480178") != std::string::npos,
|
||
"invoice: customer series shown separately");
|
||
Check(eu.find("* Invoice number: 7") != std::string::npos,
|
||
"invoice: sequence within the series");
|
||
Check(eu.find("Chico Mendesring 256") != std::string::npos, "invoice: seller address");
|
||
Check(eu.find("3315NN Dordrecht") != std::string::npos, "invoice: seller city");
|
||
Check(eu.find("KVK 78437059") != std::string::npos, "invoice: KVK");
|
||
Check(eu.find("NL003329281B38") != std::string::npos, "invoice: VAT id");
|
||
Check(eu.find("CC-TEST01") != std::string::npos, "invoice: order reference");
|
||
Check(eu.find("Ada Lovelace") != std::string::npos, "invoice: buyer name");
|
||
Check(eu.find("Fairphone 6 — Forest Green") != std::string::npos,
|
||
"invoice: item names the colour");
|
||
Check(eu.find("VAT 21% (NL)") != std::string::npos, "invoice: EU VAT line");
|
||
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;
|
||
o.shippingMinor = 2395;
|
||
o.totalMinor = 95502;
|
||
const std::string ex = Server::BuildInvoiceMarkdown(o, "Fairphone 6", "Forest Green");
|
||
Check(ex.find("VAT 0%") != std::string::npos, "invoice: export VAT 0%");
|
||
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.
|
||
o.vatIncluded = true;
|
||
o.buyer.country = "NL";
|
||
o.goodsMinor = 112660;
|
||
o.shippingMinor = 863;
|
||
o.totalMinor = 113523;
|
||
const std::string mail = Server::BuildOrderConfirmationEmail(
|
||
o, "Fairphone 6", "Forest Green", "Catcrafts <info@catcrafts.net>",
|
||
"https://catcrafts.net/order/0123456789abcdef0123456789abcdef",
|
||
"SIGNED-INVOICE-STAND-IN\n", "Fri, 08 Aug 2026 10:00:00 +0000");
|
||
Check(mail.find("From: Catcrafts <info@catcrafts.net>\n") != std::string::npos,
|
||
"email: From header");
|
||
Check(mail.find("To: b@example.org\n") != std::string::npos, "email: To header");
|
||
Check(mail.find("Subject: Catcrafts order CC-TEST01 confirmed\n") != std::string::npos,
|
||
"email: subject carries the reference");
|
||
Check(mail.find("Date: Fri, 08 Aug 2026 10:00:00 +0000\n") != std::string::npos,
|
||
"email: date header");
|
||
Check(mail.find("Message-ID: <0123456789abcdef0123456789abcdef@catcrafts.net>\n")
|
||
!= std::string::npos,
|
||
"email: message id from the token");
|
||
Check(mail.find("MIME-Version: 1.0\n") != std::string::npos, "email: mime version");
|
||
Check(mail.find("multipart/mixed") != std::string::npos, "email: multipart");
|
||
Check(mail.find("Fairphone 6 — Forest Green × 2") != std::string::npos,
|
||
"email: item names colour and quantity");
|
||
Check(mail.find("€1135.23") != std::string::npos, "email: total");
|
||
Check(mail.find("incl. 21% NL VAT") != std::string::npos, "email: EU VAT wording");
|
||
Check(mail.find("* Paid via: ideal\n") != std::string::npos, "email: payment method");
|
||
Check(mail.find("https://catcrafts.net/order/0123456789abcdef0123456789abcdef")
|
||
!= std::string::npos,
|
||
"email: order page link");
|
||
Check(mail.find("filename=\"catcrafts-invoice-"
|
||
"f57c6512-f012-4b91-adb3-077876480178-7.md\"") != std::string::npos,
|
||
"email: attachment filename is the invoice number");
|
||
Check(mail.find("SIGNED-INVOICE-STAND-IN\n") != std::string::npos,
|
||
"email: attachment body verbatim");
|
||
Check(mail.find("--=_cc_0123456789abcdef0123456789abcdef--\n") != std::string::npos,
|
||
"email: multipart closes");
|
||
Check(mail.find("KVK 78437059") != std::string::npos, "email: footer identity");
|
||
|
||
// The export wording mirrors the invoice's VAT treatment.
|
||
o.vatIncluded = false;
|
||
o.buyer.country = "GB";
|
||
o.totalMinor = 95502;
|
||
const std::string exMail = Server::BuildOrderConfirmationEmail(
|
||
o, "Fairphone 6", "Forest Green", "Catcrafts <info@catcrafts.net>",
|
||
"https://catcrafts.net/order/x", "S\n", "Fri, 08 Aug 2026 10:00:00 +0000");
|
||
Check(exMail.find("zero-rated export") != std::string::npos,
|
||
"email: export VAT wording");
|
||
Check(exMail.find("€955.02") != std::string::npos, "email: export total");
|
||
|
||
// A single unit does not advertise a quantity.
|
||
o.quantity = 1;
|
||
const std::string one = Server::BuildOrderConfirmationEmail(
|
||
o, "Fairphone 6", "Forest Green", "Catcrafts <info@catcrafts.net>",
|
||
"https://catcrafts.net/order/x", "S\n", "Fri, 08 Aug 2026 10:00:00 +0000");
|
||
Check(one.find("Forest Green ×") == std::string::npos, "email: qty 1 stays silent");
|
||
|
||
// The last line of defence: an address that could smuggle a header
|
||
// yields NO message at all, however it got into the record.
|
||
o.buyer.email = "a@b.example\nBcc: leak@evil.example";
|
||
Check(Server::BuildOrderConfirmationEmail(
|
||
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;
|
||
}
|
||
return 0;
|
||
}
|