This commit is contained in:
parent
33c68c2f44
commit
749f525f83
44 changed files with 5380 additions and 3532 deletions
70
tests/ShouldBootWasmAtDepth/main.cpp
Normal file
70
tests/ShouldBootWasmAtDepth/main.cpp
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
/*
|
||||
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 bug this suite exists for: /demos/raytracer is two segments deep, and
|
||||
// every asset the runtime needs was referenced RELATIVE to the document —
|
||||
// src="runtime.js", fetch("files.json"), fetch("variants.json"), and the
|
||||
// .wasm named by variants.json. So the browser asked for /demos/runtime.js,
|
||||
// Caddy's try_files handed back index.html, and the module was blocked for
|
||||
// being text/html. Four NS_ERROR_CORRUPTED_CONTENT failures and a blank demo.
|
||||
//
|
||||
// The server emits <base href="/"> on any page that boots wasm, which fixes
|
||||
// all of them at once. These checks pin that, and pin the precondition that
|
||||
// makes it safe: nothing else on the page may use a relative URL.
|
||||
//
|
||||
// Skips (exit 77) when no wasm bundle sits under bin/ — build the web product
|
||||
// first. CI re-runs exactly this suite after the wasm build for that reason.
|
||||
|
||||
import std;
|
||||
import Catcrafts.E2eHarness;
|
||||
|
||||
using namespace Catcrafts::E2e;
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
TestServer srv(argv[1], 8213);
|
||||
|
||||
const std::string page = srv.Body("/demos/raytracer");
|
||||
if (page.find("<script src=") == std::string::npos) {
|
||||
std::println("no bundle under bin/, so no boot scripts were emitted — "
|
||||
"build the wasm product first");
|
||||
return 77;
|
||||
}
|
||||
|
||||
Check(page.find("<base href=\"/\">") != std::string::npos,
|
||||
"wasm page sets <base href=\"/\">");
|
||||
|
||||
// Absolute script srcs regardless of the <base>, so the tags stay correct
|
||||
// even if the base is ever removed.
|
||||
{
|
||||
const std::regex relativeSrc(R"(<script\s[^>]*src="[^"/:])");
|
||||
Check(!std::regex_search(page, relativeSrc),
|
||||
"every boot script src is absolute");
|
||||
}
|
||||
|
||||
// A <base> rewrites every relative URL in the document, so it is only
|
||||
// safe while there are none. If a view ever emits href="x" or a bare
|
||||
// "#frag", the base silently retargets it — assert the precondition
|
||||
// rather than trusting it.
|
||||
{
|
||||
const std::regex urlAttr(R"lit((href|src|action)="([^"]*)")lit");
|
||||
std::size_t relative = 0;
|
||||
for (auto it = std::sregex_iterator(page.begin(), page.end(), urlAttr);
|
||||
it != std::sregex_iterator(); ++it) {
|
||||
const std::string url = (*it)[2].str();
|
||||
const bool absolute = url.starts_with("/")
|
||||
|| url.starts_with("http://")
|
||||
|| url.starts_with("https://")
|
||||
|| url.starts_with("mailto:");
|
||||
if (!absolute) ++relative;
|
||||
}
|
||||
Check(relative == 0, "wasm page has no relative URL for <base> to retarget",
|
||||
std::format("{} URL(s) would be retargeted by the base tag", relative));
|
||||
}
|
||||
|
||||
return Finish();
|
||||
}
|
||||
149
tests/ShouldBuildInvoices/main.cpp
Normal file
149
tests/ShouldBuildInvoices/main.cpp
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
/*
|
||||
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);
|
||||
}
|
||||
|
||||
} // 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");
|
||||
|
||||
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 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");
|
||||
|
||||
if (failures != 0) {
|
||||
std::println(std::cerr, "{} check(s) failed", failures);
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
137
tests/ShouldBuildMediaLadders/main.cpp
Normal file
137
tests/ShouldBuildMediaLadders/main.cpp
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
/*
|
||||
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 format ladder. One <picture>/<video> builder serves both the cards and
|
||||
// the post bodies, so these assertions cover every image and video the site
|
||||
// emits — and the ordering ones matter: a browser takes the FIRST source it
|
||||
// understands, so a mis-ordered ladder silently serves the wrong tier to
|
||||
// everyone rather than failing visibly.
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
void CheckEq(const Html::SafeHtml& actual, std::string_view expected, std::string_view what) {
|
||||
Check(actual.View() == expected, what, actual.View());
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
auto img = [](std::string src, std::string avif, std::string png,
|
||||
std::int64_t w = 0, std::int64_t h = 0) {
|
||||
PostMedia m;
|
||||
m.src = std::move(src);
|
||||
m.kind = "image";
|
||||
m.avif = std::move(avif);
|
||||
m.fallback = std::move(png);
|
||||
m.width = w;
|
||||
m.height = h;
|
||||
return m;
|
||||
};
|
||||
|
||||
// The full ladder: AVIF, then the mirrored original, then the PNG the <img>
|
||||
// itself points at. Exactly one of the three is ever fetched.
|
||||
CheckEq(Media::Tag(img("/media/x.webp", "/media/x.avif", "/media/x.png", 800, 600)),
|
||||
R"(<picture><source srcset="/media/x.avif" type="image/avif">)"
|
||||
R"(<source srcset="/media/x.webp" type="image/webp">)"
|
||||
R"(<img class="post-media__item" loading="lazy" decoding="async" alt="")"
|
||||
R"( src="/media/x.png" width="800" height="600"></picture>)",
|
||||
"media: image ladder is avif, original, png");
|
||||
|
||||
// Alt text reaches the <img>, not the <picture> — a screen reader reads the
|
||||
// img, and an alt on the wrapper is invisible to it.
|
||||
Check(Media::Tag(img("/media/x.webp", "/media/x.avif", "/media/x.png"), "a cat")
|
||||
.View().find(R"(alt="a cat" src="/media/x.png")") != std::string_view::npos,
|
||||
"media: alt lands on the img");
|
||||
|
||||
// Degradation, one tier at a time. Each of these is a real state: no
|
||||
// encoder on the build host, a source that was already PNG, a body image
|
||||
// whose download failed so there is nothing but the original URL.
|
||||
CheckEq(Media::Tag(img("/media/x.webp", "", "/media/x.png")),
|
||||
R"(<picture><source srcset="/media/x.webp" type="image/webp">)"
|
||||
R"(<img class="post-media__item" loading="lazy" decoding="async" alt="")"
|
||||
R"( src="/media/x.png"></picture>)",
|
||||
"media: no avif still offers the original above the png");
|
||||
CheckEq(Media::Tag(img("/media/x.webp", "/media/x.avif", "")),
|
||||
R"(<picture><source srcset="/media/x.avif" type="image/avif">)"
|
||||
R"(<img class="post-media__item" loading="lazy" decoding="async" alt="")"
|
||||
R"( src="/media/x.webp"></picture>)",
|
||||
"media: no png leaves the original as the base");
|
||||
CheckEq(Media::Tag(img("/media/x.webp", "", "")),
|
||||
R"(<img class="post-media__item" loading="lazy" decoding="async" alt="")"
|
||||
R"( src="/media/x.webp">)",
|
||||
"media: no renditions is a bare img, as before any of this existed");
|
||||
// A source that is already PNG is its own fallback, and must not be
|
||||
// offered twice — once as a <source> and once as the <img>.
|
||||
CheckEq(Media::Tag(img("/media/x.png", "/media/x.avif", "/media/x.png")),
|
||||
R"(<picture><source srcset="/media/x.avif" type="image/avif">)"
|
||||
R"(<img class="post-media__item" loading="lazy" decoding="async" alt="")"
|
||||
R"( src="/media/x.png"></picture>)",
|
||||
"media: a png source is not also listed as a source");
|
||||
// Likewise a source that is already AVIF.
|
||||
Check(Media::Tag(img("/media/x.avif", "/media/x.avif", "/media/x.png"))
|
||||
.View().find("image/avif\"><source") == std::string_view::npos,
|
||||
"media: an avif source is not listed twice");
|
||||
|
||||
// A URL is still a URL: the scheme allowlist applies to srcset exactly as
|
||||
// it does to src, or the ladder becomes a way around it.
|
||||
Check(Media::Tag(img("/media/x.webp", "javascript:alert(1)", "/media/x.png"))
|
||||
.View().find(R"(srcset="#")") != std::string_view::npos,
|
||||
"media: a hostile srcset is neutralised");
|
||||
|
||||
// Video is unchanged by any of this and must stay so.
|
||||
{
|
||||
PostMedia v;
|
||||
v.src = "/media/v.mp4";
|
||||
v.kind = "video";
|
||||
v.poster = "/media/v.webp";
|
||||
v.fallback = "/media/v.h264.mp4";
|
||||
const auto out = Media::Tag(v);
|
||||
Check(out.View().starts_with("<video class=\"post-media__item\" controls preload=\"metadata\""),
|
||||
"media: video is still a video", out.View());
|
||||
Check(out.View().find("codecs=av01") != std::string_view::npos
|
||||
&& out.View().find(R"(<source src="/media/v.h264.mp4" type="video/mp4">)")
|
||||
!= std::string_view::npos,
|
||||
"media: AV1 then H.264, in that order");
|
||||
Check(out.View().find("<picture>") == std::string_view::npos,
|
||||
"media: a video is not wrapped in a picture");
|
||||
}
|
||||
|
||||
// A path with no record renders from the path alone — the mirror failed,
|
||||
// and showing the picture beats dropping the paragraph's subject.
|
||||
{
|
||||
const std::array<PostMedia, 1> known{
|
||||
img("/media/x.webp", "/media/x.avif", "/media/x.png") };
|
||||
Check(Media::Find(known, "/media/x.webp") != nullptr, "media: found by src");
|
||||
Check(Media::Find(known, "/media/nope.webp") == nullptr, "media: unknown src");
|
||||
const PostMedia guessed = Media::Describe(known, "https://i.example/a.webp");
|
||||
Check(guessed.kind == "image" && guessed.avif.empty(),
|
||||
"media: an unmirrored image is described from its path");
|
||||
Check(Media::Describe(known, "https://i.example/a.mp4").kind == "video",
|
||||
"media: an unmirrored video is recognised as one");
|
||||
}
|
||||
|
||||
if (failures != 0) {
|
||||
std::println(std::cerr, "{} check(s) failed", failures);
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
167
tests/ShouldComputeMoney/main.cpp
Normal file
167
tests/ShouldComputeMoney/main.cpp
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
/*
|
||||
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 money layer: formatting, the VAT arithmetic, zones and the sale policy,
|
||||
// the carrier weight brackets, order totals, indicative currency conversion
|
||||
// and the ECB rates loader. Every price the site shows or charges goes
|
||||
// through these functions.
|
||||
|
||||
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::Money;
|
||||
|
||||
// ── formatting ────────────────────────────────────────────────────
|
||||
Check(FormatMinor(58000) == "580.00", "money: wire format");
|
||||
Check(FormatMinor(47934) == "479.34", "money: wire format with cents");
|
||||
Check(FormatMinor(5) == "0.05", "money: sub-unit");
|
||||
Check(FormatMinor(0) == "0.00", "money: zero");
|
||||
Check(FormatEuro(58000) == "€580", "money: whole euros displayed bare");
|
||||
Check(FormatEuro(47934) == "€479.34", "money: cents displayed when present");
|
||||
|
||||
// ── VAT arithmetic ────────────────────────────────────────────────
|
||||
// €580.00 gross at 21%: net = 58000/1.21 = 47933.88... -> 47934 half-up.
|
||||
Check(NetFromGross(58000) == 47934, "vat: net from €580 gross");
|
||||
// The derived pair must reconstruct plausibly: net + vat == gross.
|
||||
Check(58000 - NetFromGross(58000) == 10066, "vat: vat portion exact");
|
||||
Check(NetFromGross(0) == 0, "vat: zero");
|
||||
Check(NetFromGross(121) == 100, "vat: €1.21 -> €1.00 exactly");
|
||||
// The gross-up direction, used to charge carrier costs without eating
|
||||
// the VAT slice: €7.13 cost -> €8.63 charged, and the pair round-trips.
|
||||
Check(GrossFromNet(713) == 863, "vat: gross from €7.13 net");
|
||||
Check(NetFromGross(GrossFromNet(713)) == 713, "vat: gross-up round-trips");
|
||||
Check(GrossFromNet(100) == 121, "vat: €1.00 -> €1.21 exactly");
|
||||
Check(GrossFromNet(0) == 0, "vat: gross-up zero");
|
||||
|
||||
// ── zones and membership ──────────────────────────────────────────
|
||||
Check(IsEuCountry("NL") && IsEuCountry("DE") && IsEuCountry("FR"), "eu: members");
|
||||
Check(!IsEuCountry("GB"), "eu: UK left");
|
||||
Check(!IsEuCountry("CH") && !IsEuCountry("NO"), "eu: EFTA is not EU");
|
||||
Check(!IsEuCountry("CA") && !IsEuCountry("US"), "eu: north america");
|
||||
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("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 ──────────────────────────────────────────────────
|
||||
|
||||
// NL: gross + shipping, VAT included in both.
|
||||
auto nl = ComputeTotals(58000, 1, 1500, "NL");
|
||||
Check(nl.goods == 58000 && nl.shipping == 1500 && nl.total == 59500,
|
||||
"totals: NL");
|
||||
Check(nl.vatIncluded, "totals: NL includes VAT");
|
||||
Check(nl.vatCharged == 59500 - NetFromGross(59500), "totals: NL VAT covers shipping");
|
||||
|
||||
auto de = ComputeTotals(58000, 1, 2500, "DE");
|
||||
Check(de.goods == 58000 && de.shipping == 2500 && de.total == 60500,
|
||||
"totals: EU");
|
||||
|
||||
// Export: net goods, world shipping, no VAT.
|
||||
auto gb = ComputeTotals(58000, 1, 5500, "GB");
|
||||
Check(gb.goods == 47934 && gb.shipping == 5500 && gb.total == 53434,
|
||||
"totals: export");
|
||||
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 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");
|
||||
|
||||
// ── 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 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 (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);
|
||||
}
|
||||
|
||||
// ── rates loader ──────────────────────────────────────────────────
|
||||
const Rates r = LoadRates(
|
||||
R"({"date":"2026-08-04","micro_per_eur":{"USD":1083400,"CAD":1489000}})");
|
||||
Check(r.date == "2026-08-04", "rates: date");
|
||||
Check(r.Find("USD") == 1'083'400, "rates: lookup");
|
||||
Check(r.Find("XXX") == 0, "rates: absent is zero");
|
||||
Check(LoadRates("garbage").microPerEur.empty(), "rates: malformed input yields none");
|
||||
|
||||
if (failures != 0) {
|
||||
std::println(std::cerr, "{} check(s) failed", failures);
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
175
tests/ShouldEmitStructuredData/main.cpp
Normal file
175
tests/ShouldEmitStructuredData/main.cpp
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
/*
|
||||
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 JSON-LD blocks are the machine-readable identity and offer records,
|
||||
// born from a Google AI overview flatly asserting that nobody named Catcrafts
|
||||
// sells Fairphone hardware. Parsed with our own JSON reader — the same one
|
||||
// that must accept them — and checked for the facts.
|
||||
|
||||
import std;
|
||||
import Catcrafts.Shared;
|
||||
import Catcrafts.E2eHarness;
|
||||
|
||||
using namespace Catcrafts;
|
||||
using namespace Catcrafts::E2e;
|
||||
|
||||
namespace {
|
||||
|
||||
// Every <script type="application/ld+json"> block on the page, parsed. The
|
||||
// block content is pure JSON, which contains no '<', so slicing to the next
|
||||
// tag is exact.
|
||||
std::vector<Json::Value> ExtractLd(TestServer& srv, const std::string& path) {
|
||||
const std::string body = srv.Body(path);
|
||||
constexpr std::string_view kOpen = "<script type=\"application/ld+json\">";
|
||||
std::vector<Json::Value> out;
|
||||
for (std::size_t pos = body.find(kOpen); pos != std::string::npos;
|
||||
pos = body.find(kOpen, pos + kOpen.size())) {
|
||||
const std::size_t start = pos + kOpen.size();
|
||||
const std::size_t end = body.find('<', start);
|
||||
if (end == std::string::npos) break;
|
||||
if (auto v = Json::Parse(std::string_view(body).substr(start, end - start))) {
|
||||
out.push_back(std::move(*v));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
TestServer srv(argv[1], 8212);
|
||||
|
||||
// ── the home identity graph ───────────────────────────────────────
|
||||
// The home record is an @graph (Organization + WebSite joined by @id);
|
||||
// the org node inside it must carry the registered identity.
|
||||
{
|
||||
bool ok = false;
|
||||
for (const Json::Value& doc : ExtractLd(srv, "/")) {
|
||||
const Json::Value* g = doc.Find("@graph");
|
||||
if (!g || !g->IsArray()) continue;
|
||||
std::size_t orgs = 0;
|
||||
for (const Json::Value& node : g->array) {
|
||||
if (node.Str("@type") == "Organization"
|
||||
&& node.Str("vatID") == "NL003329281B38") {
|
||||
++orgs;
|
||||
}
|
||||
}
|
||||
ok = ok || orgs == 1;
|
||||
}
|
||||
Check(ok, "home Organization schema parses and carries the VAT identity");
|
||||
}
|
||||
|
||||
// ── the product record ────────────────────────────────────────────
|
||||
// Variants are a ProductGroup: one variant Product per colour, each with
|
||||
// its own single offer — not one Product with three prices.
|
||||
const std::vector<Json::Value> productLd = ExtractLd(srv, "/shop/fp6-pmos");
|
||||
const Json::Value* group = nullptr;
|
||||
for (const Json::Value& doc : productLd) {
|
||||
if (doc.Str("@type") == "ProductGroup") group = &doc;
|
||||
}
|
||||
{
|
||||
bool variantsOk = false;
|
||||
if (group) {
|
||||
if (const Json::Value* v = group->Find("hasVariant"); v && v->IsArray()
|
||||
&& v->array.size() == 3) {
|
||||
variantsOk = true;
|
||||
for (const Json::Value& node : v->array) {
|
||||
const Json::Value* offers = node.Find("offers");
|
||||
variantsOk = variantsOk && offers && offers->IsObject();
|
||||
}
|
||||
}
|
||||
}
|
||||
Check(variantsOk, "product schema parses with one variant (and offer) per colour");
|
||||
}
|
||||
{
|
||||
bool listOk = false;
|
||||
for (const Json::Value& doc : ExtractLd(srv, "/shop")) {
|
||||
if (doc.Str("@type") == "ItemList") {
|
||||
const Json::Value* items = doc.Find("itemListElement");
|
||||
listOk = items && items->IsArray() && !items->array.empty();
|
||||
}
|
||||
}
|
||||
Check(listOk, "shop index carries an ItemList of the product pages");
|
||||
}
|
||||
srv.BodyHas("/shop/fp6-pmos", "\"price\":\"563.30\"", "schema price is the checkout integer");
|
||||
// Merchant-grade offer fields: what Merchant Center's website-crawl feed
|
||||
// reads. Shipping is published from the live carrier table at one unit's
|
||||
// weight — the same integers checkout charges, so the listing cannot
|
||||
// quote a rate the till won't honour; returns mirror the terms page.
|
||||
srv.BodyHas("/shop/fp6-pmos", "OfferShippingDetails", "offers carry shipping details");
|
||||
srv.BodyHas("/shop/fp6-pmos", "MerchantReturnPolicy", "offers carry a return policy");
|
||||
srv.BodyHas("/shop/fp6-pmos", "\"sku\":\"fp6-pmos-green\"", "offers carry per-variant skus");
|
||||
srv.BodyHas("/shop/fp6-pmos", "\"brand\":{\"@type\":\"Brand\",\"name\":\"Fairphone\"}",
|
||||
"product carries the hardware brand");
|
||||
|
||||
// One entry per (transit tier, price) the carrier table produces — three,
|
||||
// for the fixture's NL/DE/GB. Not a fixed property of the code any more:
|
||||
// it is whatever the carrier prices, which is the point.
|
||||
const Json::Value* shipping = nullptr;
|
||||
if (group) {
|
||||
if (const Json::Value* v = group->Find("hasVariant"); v && v->IsArray()
|
||||
&& !v->array.empty()) {
|
||||
if (const Json::Value* offers = v->array[0].Find("offers")) {
|
||||
shipping = offers->Find("shippingDetails");
|
||||
}
|
||||
}
|
||||
}
|
||||
Check(shipping && shipping->IsArray() && shipping->array.size() == 3,
|
||||
"shipping details group the carrier's rates");
|
||||
// The advertised rate IS the carrier's single-unit price, and a
|
||||
// destination the table does not cover is never advertised.
|
||||
if (shipping && shipping->IsArray()) {
|
||||
std::vector<std::string> rates;
|
||||
bool au = false;
|
||||
for (const Json::Value& detail : shipping->array) {
|
||||
if (const Json::Value* rate = detail.Find("shippingRate")) {
|
||||
rates.emplace_back(rate->Str("value"));
|
||||
}
|
||||
if (const Json::Value* dest = detail.Find("shippingDestination")) {
|
||||
if (const Json::Value* cc = dest->Find("addressCountry"); cc && cc->IsArray()) {
|
||||
for (const Json::Value& c : cc->array) au = au || c.string == "AU";
|
||||
}
|
||||
}
|
||||
}
|
||||
std::ranges::sort(rates);
|
||||
Check(rates == std::vector<std::string>{ "15.00", "25.00", "55.00" },
|
||||
"published shipping rates come from the carrier table");
|
||||
Check(!au, "an uncovered destination is not advertised");
|
||||
}
|
||||
|
||||
if (srv.ShopOpen()) {
|
||||
srv.BodyHas("/shop/fp6-pmos", "schema.org/InStock",
|
||||
"open shop maps to InStock availability");
|
||||
} else {
|
||||
srv.BodyHas("/shop/fp6-pmos", "schema.org/PreOrder",
|
||||
"coming-soon maps to PreOrder availability");
|
||||
}
|
||||
|
||||
// og: tags are the link-preview card on Mastodon and Lemmy — where the
|
||||
// traffic actually comes from.
|
||||
srv.BodyHas("/", "property=\"og:title\"", "home page has og:title");
|
||||
srv.BodyHas("/shop/fp6-pmos", "og:image\" content=\"https://catcrafts.net/fp6-pmos.jpg",
|
||||
"product og:image is the absolute photo URL");
|
||||
|
||||
// ── the person-company weld ───────────────────────────────────────
|
||||
// The about page: the founder must be named in the HTML, the Person
|
||||
// schema must parse, and the home page byline must link it.
|
||||
srv.BodyHas("/about", "Jorijn van der Graaf", "about page names the founder");
|
||||
srv.BodyHas("/", "Jorijn van der Graaf", "home page carries the founder byline");
|
||||
{
|
||||
bool personOk = false;
|
||||
for (const Json::Value& doc : ExtractLd(srv, "/about")) {
|
||||
if (const Json::Value* person = doc.Find("mainEntity")) {
|
||||
personOk = personOk || person->Str("name") == "Jorijn van der Graaf";
|
||||
}
|
||||
}
|
||||
Check(personOk, "about Person schema parses and names the founder");
|
||||
}
|
||||
|
||||
return Finish();
|
||||
}
|
||||
107
tests/ShouldEscapeHtml/main.cpp
Normal file
107
tests/ShouldEscapeHtml/main.cpp
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
/*
|
||||
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 Html layer — escaping, attribute building and the URL scheme allowlist.
|
||||
// Catcrafts.Shared is the security boundary for every piece of markup the
|
||||
// site emits, and this suite is the direct test of that boundary.
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
void CheckEq(const Html::SafeHtml& actual, std::string_view expected, std::string_view what) {
|
||||
Check(actual.View() == expected, what, actual.View());
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
using namespace Catcrafts::Html;
|
||||
|
||||
// ── Escape ────────────────────────────────────────────────────────
|
||||
CheckEq(Escape("plain"), "plain", "escape: passthrough");
|
||||
CheckEq(Escape("a<b"), "a<b", "escape: lt");
|
||||
CheckEq(Escape("a>b"), "a>b", "escape: gt");
|
||||
CheckEq(Escape("a&b"), "a&b", "escape: amp");
|
||||
CheckEq(Escape("say \"hi\""), "say "hi"", "escape: dquote");
|
||||
CheckEq(Escape("it's"), "it's", "escape: squote");
|
||||
// Ampersand must be escaped first or the other replacements get
|
||||
// double-encoded; a single pass makes that ordering bug impossible.
|
||||
CheckEq(Escape("<"), "&lt;", "escape: no double-encode");
|
||||
CheckEq(Escape("<script>alert(1)</script>"),
|
||||
"<script>alert(1)</script>", "escape: script tag");
|
||||
// Non-ASCII passes through untouched — the output is UTF-8, and
|
||||
// entity-encoding it would just bloat the page.
|
||||
CheckEq(Escape("café ✓ 日本"), "café ✓ 日本", "escape: utf-8 passthrough");
|
||||
CheckEq(Escape(""), "", "escape: empty");
|
||||
|
||||
// ── Num ───────────────────────────────────────────────────────────
|
||||
CheckEq(Num(0), "0", "num: zero");
|
||||
CheckEq(Num(-42), "-42", "num: negative");
|
||||
CheckEq(Num(9007199254740993LL), "9007199254740993", "num: beyond double precision");
|
||||
|
||||
// ── Attr ──────────────────────────────────────────────────────────
|
||||
CheckEq(Attr("class", "card"), " class=\"card\"", "attr: basic");
|
||||
CheckEq(Attr("data-x", "a\"b"), " data-x=\"a"b\"", "attr: value escaped");
|
||||
CheckEq(Attr("class", ""), "", "attr: empty value omits attribute");
|
||||
// An invalid name is a programming error, not user data. Emitting
|
||||
// nothing is safer than emitting mangled markup.
|
||||
CheckEq(Attr("on error", "x"), "", "attr: invalid name rejected");
|
||||
CheckEq(Attr("x><script", "y"), "", "attr: name cannot break out");
|
||||
|
||||
// ── Url ───────────────────────────────────────────────────────────
|
||||
CheckEq(Url("href", "/shop/thing"), " href=\"/shop/thing\"", "url: site-relative");
|
||||
CheckEq(Url("href", "https://a.example/x"), " href=\"https://a.example/x\"", "url: https");
|
||||
CheckEq(Url("href", "mailto:a@b.example"), " href=\"mailto:a@b.example\"", "url: mailto");
|
||||
CheckEq(Url("href", "#reviews"), " href=\"#reviews\"", "url: fragment");
|
||||
// Escaping alone would NOT make these safe: they contain no character
|
||||
// that needs escaping, so only a scheme allowlist stops them.
|
||||
CheckEq(Url("href", "javascript:alert(1)"), " href=\"#\"", "url: javascript: neutralised");
|
||||
CheckEq(Url("href", "JaVaScRiPt:alert(1)"), " href=\"#\"", "url: case-insensitive");
|
||||
CheckEq(Url("href", "data:text/html,<script>"), " href=\"#\"", "url: data: neutralised");
|
||||
// Browsers strip control characters before resolving the scheme, so a
|
||||
// naive prefix check would pass this straight through.
|
||||
CheckEq(Url("href", "java\tscript:alert(1)"), " href=\"#\"", "url: embedded tab");
|
||||
CheckEq(Url("href", " javascript:alert(1)"), " href=\"#\"", "url: leading space");
|
||||
CheckEq(Url("href", "//evil.example/x"), " href=\"#\"", "url: protocol-relative blocked");
|
||||
CheckEq(Url("href", "vbscript:x"), " href=\"#\"", "url: vbscript neutralised");
|
||||
|
||||
// ── Format ────────────────────────────────────────────────────────
|
||||
// The compile-time half of this guarantee (raw std::string rejected) is
|
||||
// verified by the build itself — see the negative test in the notes.
|
||||
CheckEq(Format("<h2>{}</h2>", Escape("a<b")), "<h2>a<b</h2>", "format: escapes flow through");
|
||||
CheckEq(Format("<a{}>{}</a>", Url("href", "/x"), Escape("go")),
|
||||
"<a href=\"/x\">go</a>", "format: attr + text");
|
||||
CheckEq(Format("{}{}", Num(1), Num(2)), "12", "format: multiple args");
|
||||
CheckEq(Format("literal"), "literal", "format: no args");
|
||||
CheckEq(Format("{{literal braces}}"), "{literal braces}", "format: brace escaping");
|
||||
|
||||
// ── Join / concat ─────────────────────────────────────────────────
|
||||
const std::array<Html::SafeHtml, 3> parts{ Escape("a"), Escape("b"), Escape("c") };
|
||||
CheckEq(Join(parts, Raw(", ")), "a, b, c", "join: separator");
|
||||
CheckEq(Join(std::span<const Html::SafeHtml>{}), "", "join: empty");
|
||||
CheckEq(Escape("a") + Escape("<"), "a<", "operator+: escapes preserved");
|
||||
|
||||
if (failures != 0) {
|
||||
std::println(std::cerr, "{} check(s) failed", failures);
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
89
tests/ShouldGuardRequestProvenance/main.cpp
Normal file
89
tests/ShouldGuardRequestProvenance/main.cpp
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
// Request provenance: the forwarded-address parser the rate limiter keys on,
|
||||
// and the Origin check that gates the order form.
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
// 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");
|
||||
}
|
||||
|
||||
if (failures != 0) {
|
||||
std::println(std::cerr, "{} check(s) failed", failures);
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
49
tests/ShouldMintOrderTokens/main.cpp
Normal file
49
tests/ShouldMintOrderTokens/main.cpp
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
// Order tokens and the human-facing references derived from them. The token
|
||||
// is the only credential an order page has, so its shape check is a security
|
||||
// boundary: anything that is not exactly 32 lowercase hex characters must
|
||||
// never reach a lookup.
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
Check(IsOrderToken("0123456789abcdef0123456789abcdef"), "token: valid shape");
|
||||
Check(!IsOrderToken("0123456789ABCDEF0123456789ABCDEF"), "token: uppercase rejected");
|
||||
Check(!IsOrderToken("0123456789abcdef0123456789abcde"), "token: short rejected");
|
||||
Check(!IsOrderToken("0123456789abcdef0123456789abcdeg"), "token: non-hex rejected");
|
||||
const std::string tok = Server::NewOrderToken();
|
||||
Check(IsOrderToken(tok), "token: generator emits valid tokens", tok);
|
||||
Check(Server::NewOrderToken() != tok, "token: not constant");
|
||||
Check(Server::ReferenceFromToken("abcdef0123456789abcdef0123456789") == "CC-ABCDEF",
|
||||
"reference: derived and uppercased");
|
||||
|
||||
if (failures != 0) {
|
||||
std::println(std::cerr, "{} check(s) failed", failures);
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
93
tests/ShouldParseEurcChains/main.cpp
Normal file
93
tests/ShouldParseEurcChains/main.cpp
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
/*
|
||||
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 EURC rail's two parsers.
|
||||
//
|
||||
// The balance decoder is the line between "the buyer paid" and "the node
|
||||
// errored", and the chains parser is the line between "watching a chain"
|
||||
// and "silently not watching it". Both refuse rather than guess.
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
using Server::ParseEthCallUint;
|
||||
Check(ParseEthCallUint(R"({"jsonrpc":"2.0","id":1,"result":"0x0"})") == 0,
|
||||
"eurc: zero balance");
|
||||
Check(ParseEthCallUint(
|
||||
R"({"jsonrpc":"2.0","id":1,"result":"0x0000000000000000000000000000000000000000000000000000000022001230"})")
|
||||
== 570430000,
|
||||
"eurc: €570.43 as 6-decimal base units, full 32-byte word");
|
||||
Check(ParseEthCallUint(R"({"result":"0xFF"})") == 255,
|
||||
"eurc: uppercase hex accepted");
|
||||
// 2^63 does not fit; the decoder must saturate, never wrap to negative.
|
||||
Check(ParseEthCallUint(R"({"result":"0x8000000000000000"})")
|
||||
== std::numeric_limits<std::int64_t>::max(),
|
||||
"eurc: overflow saturates");
|
||||
Check(!ParseEthCallUint(
|
||||
R"({"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"x"}})")
|
||||
.has_value(),
|
||||
"eurc: an RPC error is not a zero balance");
|
||||
Check(!ParseEthCallUint(R"({"result":"21ff361c"})").has_value(),
|
||||
"eurc: missing 0x rejected");
|
||||
Check(!ParseEthCallUint(R"({"result":"0x"})").has_value(),
|
||||
"eurc: empty word rejected");
|
||||
Check(!ParseEthCallUint(R"({"result":42})").has_value(),
|
||||
"eurc: numeric result rejected — the wire type is a hex string");
|
||||
Check(!ParseEthCallUint("garbage").has_value(), "eurc: malformed payload");
|
||||
|
||||
const auto chains = Server::ParseEurcChains(R"({"chains":[
|
||||
{"name":"base","rpc":"https://mainnet.base.org",
|
||||
"contract":"0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42",
|
||||
"chain_id":8453,"note":"lowest fees"},
|
||||
{"name":"ethereum","rpc":"https://ethereum-rpc.publicnode.com",
|
||||
"contract":"0x1aBaEA1f7C830bD89Acc67eC4af516284b1bC33c","chain_id":1}]})");
|
||||
Check(chains.has_value() && chains->size() == 2, "eurc: chains file parses");
|
||||
if (chains && chains->size() == 2) {
|
||||
Check((*chains)[0].name == "base" && (*chains)[0].chainId == 8453,
|
||||
"eurc: file order preserved — first entry is the recommendation");
|
||||
Check((*chains)[0].contract == "0x60a3e35cc302bfa44cb288bc5a4f316fdb1adb42",
|
||||
"eurc: contract lowercased for comparison");
|
||||
Check((*chains)[0].note == "lowest fees", "eurc: note carried");
|
||||
Check((*chains)[1].blockTag == "finalized" && (*chains)[1].decimals == 6,
|
||||
"eurc: defaults");
|
||||
}
|
||||
Check(!Server::ParseEurcChains(R"({"chains":[
|
||||
{"name":"base","rpc":"https://x.org","contract":"0xdeadbeef"}]})").has_value(),
|
||||
"eurc: short contract rejects the whole file");
|
||||
Check(!Server::ParseEurcChains(R"({"chains":[
|
||||
{"name":"base","rpc":"ftp://x.org",
|
||||
"contract":"0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42"}]})").has_value(),
|
||||
"eurc: non-http rpc rejects the whole file");
|
||||
Check(!Server::ParseEurcChains(R"({"chains":[]})").has_value(),
|
||||
"eurc: empty chain list rejected");
|
||||
Check(!Server::ParseEurcChains("garbage").has_value(),
|
||||
"eurc: malformed chains file rejected");
|
||||
|
||||
if (failures != 0) {
|
||||
std::println(std::cerr, "{} check(s) failed", failures);
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
116
tests/ShouldParseJson/main.cpp
Normal file
116
tests/ShouldParseJson/main.cpp
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
/*
|
||||
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 JSON reader. It parses provider payloads and fetched post lists —
|
||||
// input from other people's servers — so the malformed-input half of this
|
||||
// suite is the part that matters most: reject, never partially accept.
|
||||
|
||||
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::Json;
|
||||
|
||||
auto ok = [](std::string_view text) { return Parse(text).has_value(); };
|
||||
auto bad = [](std::string_view text) { return !Parse(text).has_value(); };
|
||||
|
||||
// ── shapes ────────────────────────────────────────────────────────
|
||||
Check(ok("{}"), "json: empty object");
|
||||
Check(ok("[]"), "json: empty array");
|
||||
Check(ok(" \n\t {\"a\": 1} \n "), "json: surrounding whitespace");
|
||||
Check(ok("[1,2,3]"), "json: number array");
|
||||
Check(ok("{\"a\":{\"b\":[true,false,null]}}"), "json: nesting");
|
||||
|
||||
// ── malformed input must be rejected, not partially accepted ──────
|
||||
Check(bad("{"), "json: unterminated object");
|
||||
Check(bad("[1,]"), "json: trailing comma");
|
||||
Check(bad("{\"a\":1,}"), "json: trailing comma in object");
|
||||
Check(bad("{'a':1}"), "json: single quotes");
|
||||
Check(bad("\"unterminated"), "json: unterminated string");
|
||||
Check(bad("{\"a\" 1}"), "json: missing colon");
|
||||
Check(bad("nul"), "json: bad literal");
|
||||
Check(bad("{} garbage"), "json: trailing content rejected");
|
||||
Check(bad("[1,2] [3]"), "json: concatenated documents rejected");
|
||||
Check(bad("\"raw\nnewline\""), "json: control char in string");
|
||||
Check(bad("01"), "json: leading zero");
|
||||
Check(bad("+1"), "json: leading plus");
|
||||
Check(bad("1."), "json: trailing decimal point");
|
||||
Check(bad(".5"), "json: bare fraction");
|
||||
Check(bad("1e"), "json: empty exponent");
|
||||
Check(bad("1e+"), "json: exponent sign with no digits");
|
||||
Check(bad("-"), "json: lone minus");
|
||||
Check(bad("1e400"), "json: out of double range");
|
||||
Check(ok("0"), "json: zero");
|
||||
Check(ok("-0"), "json: negative zero");
|
||||
Check(ok("0.5"), "json: leading zero with fraction");
|
||||
Check(ok("-1.5e-3"), "json: full number grammar");
|
||||
Check(ok("1E+2"), "json: capital exponent");
|
||||
Check(bad(""), "json: empty input");
|
||||
|
||||
// ── string decoding ───────────────────────────────────────────────
|
||||
auto strOf = [](std::string_view doc) -> std::string {
|
||||
auto v = Parse(doc);
|
||||
if (!v || !v->IsObject()) return "<parse-failed>";
|
||||
return std::string(v->Str("k"));
|
||||
};
|
||||
Check(strOf(R"({"k":"a\"b"})") == "a\"b", "json: escaped quote");
|
||||
Check(strOf(R"({"k":"a\\b"})") == "a\\b", "json: escaped backslash");
|
||||
Check(strOf(R"({"k":"a\nb"})") == "a\nb", "json: newline escape");
|
||||
Check(strOf(R"({"k":"A"})") == "A", "json: \\u ascii");
|
||||
Check(strOf(R"({"k":"é"})") == "é", "json: \\u latin-1");
|
||||
Check(strOf(R"({"k":"日"})") == "日", "json: \\u BMP");
|
||||
// Astral plane arrives as a UTF-16 surrogate pair. Encoding each half
|
||||
// separately yields invalid UTF-8 — emoji in Lemmy post titles are
|
||||
// exactly this case, so it has to be combined.
|
||||
Check(strOf(R"({"k":"😺"})") == "\U0001F63A", "json: surrogate pair -> emoji");
|
||||
Check(strOf(R"({"k":"\ud83d"})") == "<EFBFBD>", "json: lone high surrogate -> U+FFFD");
|
||||
Check(strOf(R"({"k":"\ude3a"})") == "<EFBFBD>", "json: lone low surrogate -> U+FFFD");
|
||||
Check(strOf(R"({"k":"raw é ✓"})") == "raw é ✓", "json: raw utf-8 passthrough");
|
||||
|
||||
// ── accessors ─────────────────────────────────────────────────────
|
||||
auto doc = Parse(R"({"s":"x","n":42,"neg":-7,"b":true,"nul":null})");
|
||||
Check(doc.has_value(), "json: accessor doc parses");
|
||||
if (doc) {
|
||||
Check(doc->Str("s") == "x", "json: Str");
|
||||
Check(doc->Int("n") == 42, "json: Int");
|
||||
Check(doc->Int("neg") == -7, "json: Int negative");
|
||||
Check(doc->Bool("b"), "json: Bool");
|
||||
Check(doc->Str("missing", "fallback") == "fallback", "json: Str fallback");
|
||||
Check(doc->Int("missing", 99) == 99, "json: Int fallback");
|
||||
// Wrong-typed field falls back rather than reinterpreting.
|
||||
Check(doc->Int("s", 5) == 5, "json: type mismatch falls back");
|
||||
Check(doc->Find("missing") == nullptr, "json: Find absent");
|
||||
Check(doc->Find("nul") != nullptr && doc->Find("nul")->IsNull(),
|
||||
"json: present-null distinguishable from absent");
|
||||
}
|
||||
|
||||
// ── depth guard ───────────────────────────────────────────────────
|
||||
std::string deep(200, '[');
|
||||
Check(bad(deep), "json: deep nesting rejected, not stack overflow");
|
||||
|
||||
if (failures != 0) {
|
||||
std::println(std::cerr, "{} check(s) failed", failures);
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
82
tests/ShouldParseMolliePayments/main.cpp
Normal file
82
tests/ShouldParseMolliePayments/main.cpp
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
/*
|
||||
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 wire-amount parser (Mollie quotes amounts as strings) and the Mollie
|
||||
// payment parser — the line between "the buyer paid" and "the provider said
|
||||
// something we did not understand". Both refuse rather than guess.
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
// ── the wire-amount parser ────────────────────────────────────────
|
||||
using Server::ParseAmountToMinor;
|
||||
Check(ParseAmountToMinor("614.00") == 61400, "amount: normal");
|
||||
Check(ParseAmountToMinor("614") == 61400, "amount: no fraction");
|
||||
Check(ParseAmountToMinor("614.5") == 61450, "amount: one fraction digit");
|
||||
Check(ParseAmountToMinor("0.01") == 1, "amount: one cent");
|
||||
Check(!ParseAmountToMinor("614.005").has_value(), "amount: three decimals rejected");
|
||||
Check(!ParseAmountToMinor("-1.00").has_value(), "amount: negative rejected");
|
||||
Check(!ParseAmountToMinor("+1.00").has_value(), "amount: sign rejected");
|
||||
Check(!ParseAmountToMinor("1e3").has_value(), "amount: exponent rejected");
|
||||
Check(!ParseAmountToMinor("1.").has_value(), "amount: trailing dot rejected");
|
||||
Check(!ParseAmountToMinor(".5").has_value(), "amount: bare fraction rejected");
|
||||
Check(!ParseAmountToMinor("").has_value(), "amount: empty rejected");
|
||||
Check(!ParseAmountToMinor("1 000.00").has_value(), "amount: separator rejected");
|
||||
|
||||
// ── the Mollie payment parser ─────────────────────────────────────
|
||||
{
|
||||
const auto p1 = Server::ParseMolliePayment(R"({
|
||||
"resource":"payment","id":"tr_7UhSN1zuXS","status":"open","method":null,
|
||||
"amount":{"value":"578.30","currency":"EUR"},
|
||||
"_links":{"checkout":{"href":"https://www.mollie.com/checkout/select-method/7UhSN1zuXS","type":"text/html"}}})");
|
||||
Check(p1.has_value(), "mollie: open payment parses");
|
||||
if (p1) {
|
||||
Check(p1->id == "tr_7UhSN1zuXS", "mollie: id");
|
||||
Check(p1->status == "open", "mollie: status");
|
||||
Check(p1->amountMinor == 57830, "mollie: amount to cents");
|
||||
Check(p1->checkoutUrl == "https://www.mollie.com/checkout/select-method/7UhSN1zuXS",
|
||||
"mollie: checkout link");
|
||||
Check(p1->method.empty(), "mollie: null method is empty");
|
||||
}
|
||||
const auto p2 = Server::ParseMolliePayment(R"({
|
||||
"id":"tr_x","status":"paid","method":"ideal",
|
||||
"amount":{"value":"578.30","currency":"EUR"},"_links":{}})");
|
||||
Check(p2 && p2->status == "paid" && p2->method == "ideal",
|
||||
"mollie: paid payment carries the method");
|
||||
const auto p3 = Server::ParseMolliePayment(R"({
|
||||
"id":"tr_y","status":"paid","amount":{"value":"578.30","currency":"USD"}})");
|
||||
Check(p3 && p3->amountMinor == 0, "mollie: non-EUR amount refuses to count");
|
||||
Check(!Server::ParseMolliePayment("garbage").has_value(),
|
||||
"mollie: malformed payload rejected");
|
||||
Check(!Server::ParseMolliePayment(R"({"status":"open"})").has_value(),
|
||||
"mollie: missing id rejected");
|
||||
}
|
||||
|
||||
if (failures != 0) {
|
||||
std::println(std::cerr, "{} check(s) failed", failures);
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
102
tests/ShouldParseSendcloudRates/main.cpp
Normal file
102
tests/ShouldParseSendcloudRates/main.cpp
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
/*
|
||||
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 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.
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
const auto table = Server::ParseSendcloudMethods(R"({"shipping_methods":[
|
||||
{"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}]},
|
||||
{"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", 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 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","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","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}]},
|
||||
{"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", 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, 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");
|
||||
|
||||
if (failures != 0) {
|
||||
std::println(std::cerr, "{} check(s) failed", failures);
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
566
tests/ShouldProcessCheckout/main.cpp
Normal file
566
tests/ShouldProcessCheckout/main.cpp
Normal file
|
|
@ -0,0 +1,566 @@
|
|||
/*
|
||||
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 whole order lifecycle against the real binary: checkout, the payment
|
||||
// choice, export pricing, the refusals, the fake rail settling, the signed
|
||||
// invoice, the confirmation email, and the financials page agreeing with the
|
||||
// ledger. The fake payment rail makes this testable: it hands out pretend
|
||||
// payment links and reports "paid" once the marker file exists — which is how
|
||||
// this suite simulates the customer paying.
|
||||
//
|
||||
// The lifecycle half runs only while the shop is OPEN; while coming-soon it
|
||||
// asserts the closed behaviour instead. Launch day (status flip to
|
||||
// "available" in Catcrafts.Shared-Content.cppm) re-arms the full suite with
|
||||
// no edit here. The validation half runs in both states on purpose:
|
||||
// validation happens before the coming-soon check, and that ordering is
|
||||
// exactly what it pins.
|
||||
|
||||
import std;
|
||||
import Catcrafts.Shared;
|
||||
import Crafter.Network;
|
||||
import Catcrafts.E2eHarness;
|
||||
|
||||
using namespace Catcrafts;
|
||||
using namespace Catcrafts::E2e;
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr std::string_view kGood =
|
||||
"email=e2e%40example.org&name=Ada%20Lovelace&street=Main%20St%201&postal=1234AB&city=Delft&country=nl";
|
||||
|
||||
std::string Good(std::string_view suffix = {}) {
|
||||
return std::string(kGood) + std::string(suffix);
|
||||
}
|
||||
|
||||
// The order token from a checkout redirect's Location header.
|
||||
std::string TokenOf(const Crafter::HTTPResponse& r) {
|
||||
const auto it = r.headers.find("location");
|
||||
if (it == r.headers.end()) return {};
|
||||
std::smatch m;
|
||||
if (std::regex_search(it->second, m, std::regex(R"(/order/([0-9a-f]{32})$)"))) {
|
||||
return m[1].str();
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
// The ledger is JSONL — one event per line.
|
||||
std::vector<std::string> LedgerLines(TestServer& srv) {
|
||||
std::vector<std::string> lines;
|
||||
for (auto part : std::views::split(srv.OrdersText(), '\n')) {
|
||||
std::string_view line(part.begin(), part.end());
|
||||
if (!line.empty()) lines.emplace_back(line);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
std::size_t MailCount(TestServer& srv) {
|
||||
std::size_t n = 0;
|
||||
for (const auto& entry : std::filesystem::directory_iterator(srv.Work())) {
|
||||
const std::string name = entry.path().filename().string();
|
||||
if (name.starts_with("mail-") && name.ends_with(".eml")) ++n;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
// Wait until `predicate` holds, on the reconciler/mailer cadence.
|
||||
bool SettleUntil(std::function<bool()> predicate, std::int32_t tries = 40) {
|
||||
for (std::int32_t i = 0; i < tries; ++i) {
|
||||
if (predicate()) return true;
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(250));
|
||||
}
|
||||
return predicate();
|
||||
}
|
||||
|
||||
bool GpgVerifies(const std::filesystem::path& file) {
|
||||
return std::system(std::format("gpg --verify {} >/dev/null 2>&1",
|
||||
file.string()).c_str()) == 0;
|
||||
}
|
||||
|
||||
void OpenShopLifecycle(TestServer& srv) {
|
||||
// ── checkout ──────────────────────────────────────────────────────
|
||||
// A valid submission answers 303 straight to the PAYMENT page — no
|
||||
// interim stop. The fake rail's payUrl is the order page itself, so the
|
||||
// token is still extractable from the Location and the browser flow works
|
||||
// in dev.
|
||||
const auto checkout = srv.Post("/shop/fp6-pmos", Good());
|
||||
const std::string token = TokenOf(checkout);
|
||||
Check(checkout.status == "303" && !token.empty(),
|
||||
"POST checkout -> 303 straight to payment", checkout.status);
|
||||
{
|
||||
const std::string ledger = srv.OrdersText();
|
||||
Check(ledger.find("\"country\":\"NL\"") != std::string::npos
|
||||
&& ledger.find("\"total_minor\":57830") != std::string::npos,
|
||||
"order stored: NL total is €578.30 (green €563.30 + €15 shipping)");
|
||||
// No `pay` field in that submission, which is what a form with only
|
||||
// one rail configured posts: it must land on the bank rail rather
|
||||
// than nothing.
|
||||
Check(ledger.find("\"pay_choice\":\"bank\"") != std::string::npos,
|
||||
"a submission with no payment choice records bank");
|
||||
}
|
||||
|
||||
// The order page: awaiting payment, pay link, reference, self-refreshing,
|
||||
// never indexed, never cached.
|
||||
const std::string orderPath = std::format("/order/{}", token);
|
||||
{
|
||||
const std::string page = srv.Body(orderPath);
|
||||
for (std::string_view probe : { "awaiting payment", "Resume payment", "CC-",
|
||||
"http-equiv=\"refresh\"", "€578.30" }) {
|
||||
Check(page.find(probe) != std::string::npos,
|
||||
std::format("order page has {}", probe));
|
||||
}
|
||||
}
|
||||
srv.HeaderHas(orderPath, "x-robots-tag", "noindex", "order page is noindex");
|
||||
srv.HeaderHas(orderPath, "cache-control", "no-store", "order page is never cached");
|
||||
|
||||
// Unknown and malformed tokens are the same 404.
|
||||
srv.CheckStatus("/order/00000000000000000000000000000000", "404");
|
||||
srv.CheckStatus("/order/not-a-token", "404");
|
||||
srv.CheckStatus("/order/deadbeef", "404");
|
||||
|
||||
// ── the payment choice ────────────────────────────────────────────
|
||||
// Both slots are configured here, so the form must offer both and the
|
||||
// picked one must survive all the way into the ledger. The ledger is the
|
||||
// assertion that matters: it is what the reconciler later reads to decide
|
||||
// WHICH provider may confirm the order, so a choice that renders but is
|
||||
// not stored would mean crypto orders being asked about at Mollie.
|
||||
srv.BodyHas("/shop/fp6-pmos", "name=\"pay\"", "the form offers a payment choice");
|
||||
srv.BodyHas("/shop/fp6-pmos", "value=\"crypto\"", "crypto is one of the choices");
|
||||
srv.BodyHas("/shop/fp6-pmos", "value=\"bank\" checked", "bank is the pre-selected choice");
|
||||
|
||||
const std::string tokenCrypto = TokenOf(srv.Post("/shop/fp6-pmos", Good("&pay=crypto")));
|
||||
Check(!tokenCrypto.empty(), "a crypto order goes through");
|
||||
if (!tokenCrypto.empty()) {
|
||||
bool recorded = false;
|
||||
for (const std::string& line : LedgerLines(srv)) {
|
||||
if (line.find(std::format("\"id\":\"{}\"", tokenCrypto)) != std::string::npos) {
|
||||
recorded = recorded
|
||||
|| line.find("\"pay_choice\":\"crypto\"") != std::string::npos;
|
||||
}
|
||||
}
|
||||
Check(recorded, "the crypto choice is what the ledger records");
|
||||
// The order page has to promise what is actually behind the button —
|
||||
// the bank copy on a crypto order would send someone looking for
|
||||
// iDEAL. (The shell suite pinned 'Lightning' here, CoinGate-era copy
|
||||
// that had already left the codebase — this is the check that caught
|
||||
// the drift when the port first ran against an open shop.)
|
||||
const std::string page = srv.Body(std::format("/order/{}", tokenCrypto));
|
||||
Check(page.find("completes your crypto payment") != std::string::npos,
|
||||
"the crypto order page describes the crypto payment");
|
||||
Check(page.find("iDEAL") == std::string::npos,
|
||||
"the crypto order page does not promise iDEAL");
|
||||
}
|
||||
|
||||
// A payment method nobody offers is refused, and refused as a FIELD error
|
||||
// so the form comes back with the choice highlighted rather than a bare
|
||||
// 400.
|
||||
{
|
||||
const auto bogus = srv.Post("/shop/fp6-pmos", Good("&pay=invoice-me-later"));
|
||||
Check(bogus.status == "422", "an unknown payment method is refused", bogus.status);
|
||||
Check(bogus.body.find("Pick one of the payment methods") != std::string::npos,
|
||||
"the refusal names the payment field");
|
||||
}
|
||||
|
||||
// A non-EU order: ex-VAT goods, world shipping, and the indicative
|
||||
// national currency line sourced from the build-time ECB rates. GB rather
|
||||
// than a North American destination because those are refused outright.
|
||||
const std::string tokenGb = TokenOf(srv.Post("/shop/fp6-pmos",
|
||||
"email=gb%40example.org&name=Terry&street=1%20Baker%20St&postal=W1U&city=London&country=GB"));
|
||||
Check(!tokenGb.empty(), "GB checkout issues an order");
|
||||
if (!tokenGb.empty()) {
|
||||
const std::string page = srv.Body(std::format("/order/{}", tokenGb));
|
||||
// €465.54 goods (green net) + €55 world shipping = €520.54
|
||||
Check(page.find("€520.54") != std::string::npos,
|
||||
"export order total is ex-VAT + world shipping");
|
||||
Check(page.find("Zero-rated export") != std::string::npos,
|
||||
"export order states the VAT treatment");
|
||||
Check(std::regex_search(page, std::regex("≈ £[0-9]+")),
|
||||
"export order shows the indicative GBP amount");
|
||||
Check(page.find("indicative") != std::string::npos,
|
||||
"conversion is labelled indicative");
|
||||
}
|
||||
|
||||
// A two-unit white export order: unit €665, line €1330, net from the LINE
|
||||
// total (not per unit) = €1082.45, plus €55 world shipping = €1137.45.
|
||||
const std::string tokenWhite = TokenOf(srv.Post("/shop/fp6-pmos",
|
||||
"email=w%40example.org&name=W&street=X%201&postal=1&city=Y&country=GB&color=white&quantity=2"));
|
||||
Check(!tokenWhite.empty(), "white ×2 checkout issues an order");
|
||||
if (!tokenWhite.empty()) {
|
||||
const std::string page = srv.Body(std::format("/order/{}", tokenWhite));
|
||||
Check(page.find("€1137.45") != std::string::npos,
|
||||
"white ×2 export total nets the line, not the unit");
|
||||
Check(page.find("Device × 2") != std::string::npos, "order page shows the quantity");
|
||||
Check(page.find("White") != std::string::npos, "order page names the colour");
|
||||
}
|
||||
|
||||
// A colour we never listed must not buy anything, whatever the form claims.
|
||||
srv.CheckStatus("/shop/fp6-pmos", "422", "POST", Good("&color=mauve"));
|
||||
srv.CheckStatus("/shop/fp6-pmos", "422", "POST", Good("&quantity=100"));
|
||||
srv.CheckStatus("/shop/fp6-pmos", "422", "POST", Good("&quantity=0"));
|
||||
// Quantity is a free input with a technical ceiling, not a dropdown — a
|
||||
// nine-unit order is business, not fraud.
|
||||
Check(!TokenOf(srv.Post("/shop/fp6-pmos", Good("&quantity=9"))).empty(),
|
||||
"a nine-unit order goes through");
|
||||
srv.BodyHas("/shop/fp6-pmos", "type=\"number\"", "quantity is a number input, not a dropdown");
|
||||
// The ceiling is physical: the heaviest band any destination offers
|
||||
// (10 kg in the fixture) divided by the boxed unit weight (700 g) = 14.
|
||||
// The input advertises the BEST case across destinations; the per-country
|
||||
// limit is enforced on submit, below.
|
||||
srv.BodyHas("/shop/fp6-pmos", "max=\"14\"", "quantity input carries the one-parcel ceiling");
|
||||
|
||||
// One order is one parcel. Fifteen units is 10.5 kg, past every band the
|
||||
// fixture has, so it must be refused rather than quoted a rate the
|
||||
// carrier would not accept — and the refusal has to say what WOULD fit,
|
||||
// or the buyer is left guessing.
|
||||
{
|
||||
const auto heavy = srv.Post("/shop/fp6-pmos", Good("&quantity=15"));
|
||||
Check(heavy.status == "422", "an over-weight order is refused", heavy.status);
|
||||
Check(heavy.body.find("up to 14 per order") != std::string::npos,
|
||||
"the too-heavy refusal says what fits");
|
||||
Check(heavy.body.find("orders@catcrafts.net") != std::string::npos,
|
||||
"the too-heavy refusal offers a way to order anyway");
|
||||
}
|
||||
|
||||
// A destination the carrier has no rate for. Since the zone fallback went
|
||||
// away there is no price to invent, so this is a refusal — and
|
||||
// specifically NOT the no-sale refusal, which is a different (policy)
|
||||
// reason with different wording.
|
||||
constexpr std::string_view kAu =
|
||||
"email=au%40example.org&name=Alex&street=1%20George%20St&postal=2000&city=Sydney&country=AU";
|
||||
{
|
||||
const auto au = srv.Post("/shop/fp6-pmos", std::string(kAu));
|
||||
Check(au.status == "422", "an uncovered destination is refused", au.status);
|
||||
Check(au.body.find("No carrier rate for AU") != std::string::npos,
|
||||
"an uncovered destination is refused, naming the country");
|
||||
// The country FIELD ERROR must be the carrier message, not the
|
||||
// no-sale one. Matched on the error markup rather than the bare
|
||||
// sentence: the no-sale line is standing copy above every buy form.
|
||||
Check(au.body.find("field__error\">Catcrafts does not sell") == std::string::npos,
|
||||
"an uncovered destination is not confused with a refused one");
|
||||
const std::size_t before = LedgerLines(srv).size();
|
||||
srv.Post("/shop/fp6-pmos", std::string(kAu));
|
||||
Check(LedgerLines(srv).size() == before, "a refused destination writes no order");
|
||||
}
|
||||
|
||||
// No invoice exists before the money does — awaiting orders answer 404.
|
||||
srv.CheckStatus(std::format("/order/{}/invoice.md", token), "404");
|
||||
srv.CheckStatus("/order/00000000000000000000000000000000/invoice.md", "404");
|
||||
|
||||
// ── the payment lands ─────────────────────────────────────────────
|
||||
// Create the fake rail's paid marker, then the reconciler (1 s cadence in
|
||||
// fake mode) must flip the order within a few seconds. The paid state
|
||||
// shows the confirmation notice, deliberately WITHOUT a second "paid"
|
||||
// badge — so the success marker is the notice text.
|
||||
WriteFile(std::filesystem::path(srv.Orders().string() + ".fake-paid"), "");
|
||||
{
|
||||
const std::string page = srv.WaitForBody(orderPath, "order is confirmed");
|
||||
Check(page.find("order is confirmed") != std::string::npos,
|
||||
"order confirms after payment (arrival poll or reconciler)");
|
||||
const std::size_t badges = CountOccurrences(page, "badge--active");
|
||||
Check(badges == 0, "no duplicate paid badge next to the confirmation",
|
||||
std::format("found {} active badges", badges));
|
||||
Check(page.find("http-equiv=\"refresh\"") == std::string::npos,
|
||||
"paid order page stops self-refreshing");
|
||||
}
|
||||
{
|
||||
const std::string ledger = srv.OrdersText();
|
||||
Check(ledger.find("\"type\":\"status\"") != std::string::npos
|
||||
&& ledger.find("\"status\":\"paid\"") != std::string::npos,
|
||||
"paid transition is an appended event, not a rewrite");
|
||||
// The paid event records HOW it was paid — card money stays
|
||||
// reversible for months, so the ledger must show which orders carry
|
||||
// that tail.
|
||||
Check(ledger.find("\"via\":\"fake\"") != std::string::npos,
|
||||
"paid event records the payment method");
|
||||
}
|
||||
|
||||
// ── the signed invoice ────────────────────────────────────────────
|
||||
// Paid orders download a clearsigned markdown invoice: sequential number,
|
||||
// registered identity, amounts — and a signature that verifies offline.
|
||||
{
|
||||
const auto invoice = srv.Get(std::format("/order/{}/invoice.md", token));
|
||||
for (std::string_view probe : { "BEGIN PGP SIGNED MESSAGE", "# Invoice ",
|
||||
"Customer number: ", "Chico Mendesring 256",
|
||||
"KVK 78437059", "NL003329281B38", "CC-",
|
||||
"VAT 21% (NL)", "€578.30" }) {
|
||||
Check(invoice.body.find(probe) != std::string::npos,
|
||||
std::format("invoice has {}", probe));
|
||||
}
|
||||
const auto disposition = invoice.headers.find("content-disposition");
|
||||
Check(disposition != invoice.headers.end()
|
||||
&& disposition->second.find("attachment") != std::string::npos,
|
||||
"invoice downloads as an attachment");
|
||||
const std::filesystem::path file = srv.Work() / "invoice.md";
|
||||
WriteFile(file, invoice.body);
|
||||
Check(GpgVerifies(file), "invoice signature verifies with gpg");
|
||||
}
|
||||
|
||||
// Several orders were placed before the marker (two of them by the same
|
||||
// email); the arrival poll paid one instantly, the reconciler sweeps the
|
||||
// rest on its 1 s cadence — wait for all four invoices before judging the
|
||||
// numbering.
|
||||
SettleUntil([&] {
|
||||
return CountOccurrences(srv.OrdersText(), "\"type\":\"invoice\"") >= 4;
|
||||
});
|
||||
|
||||
// Per-customer series, continuing the pre-shop administration: numbers
|
||||
// are <customer-uuid>-<seq>, unique overall, and orders that share an
|
||||
// email share a series with distinct sequence numbers.
|
||||
{
|
||||
const std::string ledger = srv.OrdersText();
|
||||
const std::size_t invoices = CountOccurrences(ledger, "\"type\":\"invoice\"");
|
||||
std::set<std::string> numbers;
|
||||
bool uuidSeries = false;
|
||||
const std::regex numberField(R"lit("number":"([0-9a-f-]*)")lit");
|
||||
const std::regex uuidSeq(
|
||||
R"(^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12}-[0-9]+$)");
|
||||
for (auto it = std::sregex_iterator(ledger.begin(), ledger.end(), numberField);
|
||||
it != std::sregex_iterator(); ++it) {
|
||||
numbers.insert((*it)[1].str());
|
||||
uuidSeries = uuidSeries || std::regex_match((*it)[1].str(), uuidSeq);
|
||||
}
|
||||
Check(invoices > 0 && invoices == numbers.size(),
|
||||
std::format("invoice numbers are unique ({} issued)", invoices));
|
||||
Check(uuidSeries, "invoice numbers are customer-uuid series");
|
||||
// The GOOD email placed several paid orders in this run — all of them
|
||||
// must sit in ONE customer series (same uuid), with as many distinct
|
||||
// sequence numbers.
|
||||
std::set<std::string> customers;
|
||||
const std::regex customerField(R"lit("customer":"([0-9a-f-]*)")lit");
|
||||
for (auto it = std::sregex_iterator(ledger.begin(), ledger.end(), customerField);
|
||||
it != std::sregex_iterator(); ++it) {
|
||||
customers.insert((*it)[1].str());
|
||||
}
|
||||
Check(customers.size() < invoices,
|
||||
std::format("repeat customer shares one series ({} customers, {} invoices)",
|
||||
customers.size(), invoices));
|
||||
}
|
||||
|
||||
// ── the confirmation email ────────────────────────────────────────
|
||||
// Every paid order gets exactly one confirmation with the signed invoice
|
||||
// attached. The expected count comes from the LEDGER rather than a number
|
||||
// written here: "one per paid order" is the actual property, and a
|
||||
// literal would have to be edited by anyone who adds an order above — a
|
||||
// test that fails for the wrong reason and gets bumped without being
|
||||
// read. The mailer sweeps every 2 s.
|
||||
const std::size_t paidCount = CountOccurrences(srv.OrdersText(), "\"status\":\"paid\"");
|
||||
SettleUntil([&] { return MailCount(srv) >= paidCount; }, 60);
|
||||
const std::size_t mailCount = MailCount(srv);
|
||||
Check(mailCount == paidCount,
|
||||
std::format("one confirmation email per paid order ({} sent)", mailCount),
|
||||
std::format("expected {}", paidCount));
|
||||
|
||||
// The NL order's message, found by its own order link (the same email
|
||||
// address placed two orders, so the address alone would be ambiguous).
|
||||
std::string nlMail;
|
||||
std::string gbMail;
|
||||
for (const auto& entry : std::filesystem::directory_iterator(srv.Work())) {
|
||||
const std::string name = entry.path().filename().string();
|
||||
if (!name.starts_with("mail-") || !name.ends_with(".eml")) continue;
|
||||
const std::string mail = ReadFile(entry.path());
|
||||
if (mail.find(std::format("/order/{}", token)) != std::string::npos) nlMail = mail;
|
||||
if (!tokenGb.empty()
|
||||
&& mail.find(std::format("/order/{}", tokenGb)) != std::string::npos) {
|
||||
gbMail = mail;
|
||||
}
|
||||
}
|
||||
Check(!nlMail.empty(), "a confirmation email links the NL order");
|
||||
if (!nlMail.empty()) {
|
||||
for (std::string_view probe : { "To: e2e@example.org", "Subject: Catcrafts order CC-",
|
||||
"From: Catcrafts <info@catcrafts.net>",
|
||||
"MIME-Version: 1.0", "€578.30", "incl. 21% NL VAT",
|
||||
"KVK 78437059", "BEGIN PGP SIGNED MESSAGE",
|
||||
"filename=\"catcrafts-invoice-" }) {
|
||||
Check(nlMail.find(probe) != std::string::npos,
|
||||
std::format("email has {}", probe));
|
||||
}
|
||||
// The attached invoice must verify offline exactly like the download.
|
||||
const std::size_t begin = nlMail.find("-----BEGIN PGP SIGNED MESSAGE-----");
|
||||
const std::size_t end = nlMail.find("-----END PGP SIGNATURE-----");
|
||||
Check(begin != std::string::npos && end != std::string::npos,
|
||||
"email carries a full PGP block");
|
||||
if (begin != std::string::npos && end != std::string::npos) {
|
||||
const std::filesystem::path file = srv.Work() / "mail-invoice.asc";
|
||||
WriteFile(file, nlMail.substr(
|
||||
begin, end + std::string_view("-----END PGP SIGNATURE-----").size() - begin)
|
||||
+ "\n");
|
||||
Check(GpgVerifies(file), "emailed invoice signature verifies with gpg");
|
||||
}
|
||||
}
|
||||
// The export order's message states the VAT treatment its invoice carries.
|
||||
Check(!gbMail.empty() && gbMail.find("zero-rated export") != std::string::npos,
|
||||
"export confirmation states the zero-rated treatment");
|
||||
|
||||
// Idempotency comes from the ledger's notified event, not from luck in
|
||||
// timing — sit out two more sweeps and expect no extra message.
|
||||
std::this_thread::sleep_for(std::chrono::seconds(5));
|
||||
Check(MailCount(srv) == mailCount, "no order was emailed twice",
|
||||
std::format("message count grew from {} to {}", mailCount, MailCount(srv)));
|
||||
Check(srv.OrdersText().find("\"type\":\"notified\"") != std::string::npos,
|
||||
"notified events recorded in the ledger");
|
||||
|
||||
// ── financials reflect the ledger ─────────────────────────────────
|
||||
// Lifetime sales on /financials must equal the ledger: sum of total_minor
|
||||
// over orders that have a paid status event. Derived from the ledger
|
||||
// rather than written as a literal — same rule as the email count above:
|
||||
// "the page equals the ledger" is the actual property.
|
||||
{
|
||||
std::set<std::string> paidIds;
|
||||
std::int64_t wantMinor = 0;
|
||||
std::size_t wantCount = 0;
|
||||
const std::vector<std::string> lines = LedgerLines(srv);
|
||||
for (const std::string& line : lines) {
|
||||
const auto event = Json::Parse(line);
|
||||
if (!event || !event->IsObject()) continue;
|
||||
if (event->Str("type") == "status" && event->Str("status") == "paid") {
|
||||
paidIds.insert(std::string(event->Str("id")));
|
||||
}
|
||||
}
|
||||
for (const std::string& id : paidIds) {
|
||||
for (const std::string& line : lines) {
|
||||
const auto event = Json::Parse(line);
|
||||
if (!event || !event->IsObject()) continue;
|
||||
if (event->Str("type") == "order" && event->Str("id") == id) {
|
||||
wantMinor += event->Int("total_minor");
|
||||
++wantCount;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const std::string page = srv.Body("/financials");
|
||||
const std::string want = std::format(
|
||||
"data-fin-sales-minor=\"{}\"", wantMinor);
|
||||
Check(wantCount > 0 && page.find(want) != std::string::npos
|
||||
&& page.find(std::format("data-fin-sales-count=\"{}\"", wantCount))
|
||||
!= std::string::npos,
|
||||
std::format("sales totals equal the ledger ({} orders, {} cents)",
|
||||
wantCount, wantMinor));
|
||||
// And the formatted euro figure for that total appears on the page.
|
||||
const std::string euro = wantMinor % 100 == 0
|
||||
? std::format("€{}", wantMinor / 100)
|
||||
: std::format("€{}.{:02}", wantMinor / 100, wantMinor % 100);
|
||||
Check(page.find(euro) != std::string::npos,
|
||||
std::format("sales total renders as {}", euro));
|
||||
}
|
||||
}
|
||||
|
||||
void ComingSoon(TestServer& srv) {
|
||||
// A perfectly valid order must be refused while the shop is closed: after
|
||||
// validation (so the field checks below still exercise the parser) and
|
||||
// before any rail or ledger is touched.
|
||||
srv.CheckStatus("/shop/fp6-pmos", "409", "POST", Good());
|
||||
Check(srv.OrdersText().empty(), "refused order writes nothing to the ledger");
|
||||
std::println("shop is coming-soon; the checkout, order-lifecycle and invoice "
|
||||
"checks re-arm when the status flips to available");
|
||||
}
|
||||
|
||||
void AlwaysOnValidation(TestServer& srv) {
|
||||
srv.CheckStatus("/shop/fp6-pmos", "422", "POST",
|
||||
"name=Ada&street=x&postal=1&city=y&country=NL"); // no email
|
||||
srv.CheckStatus("/shop/fp6-pmos", "422", "POST",
|
||||
"email=nonsense&" + Good()); // bad email (dup keeps first)
|
||||
srv.CheckStatus("/shop/fp6-pmos", "422", "POST",
|
||||
"email=a%40b.example&country=NL"); // missing address
|
||||
srv.CheckStatus("/shop/fp6-pmos", "422", "POST", Good("&website=spam")); // honeypot
|
||||
|
||||
// Destinations the shop refuses (Money::NoSaleCountries). Well-formed,
|
||||
// real addresses: the refusal is policy, not a shape check, so it has to
|
||||
// hold for every spelling the form accepts. Deliberately outside the
|
||||
// shop-open gate — validation runs before the coming-soon check, so this
|
||||
// must answer 422 whether the shop is open or not, and it is the
|
||||
// assertion that would catch the block being lost in a refactor.
|
||||
const std::size_t before = LedgerLines(srv).size();
|
||||
srv.CheckStatus("/shop/fp6-pmos", "422", "POST",
|
||||
"email=us%40example.org&name=Pat&street=1%20Main%20St&postal=43004&city=Columbus&country=US");
|
||||
srv.CheckStatus("/shop/fp6-pmos", "422", "POST",
|
||||
"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");
|
||||
// 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");
|
||||
srv.CheckStatus("/shop/nope", "404", "POST", Good()); // unknown product
|
||||
}
|
||||
|
||||
// The re-rendered form only exists when the shop is open; while coming-soon a
|
||||
// rejection answers with the coming-soon page instead.
|
||||
void RejectedFormEcho(TestServer& srv) {
|
||||
// A rejected submission must come back with the values still in it —
|
||||
// losing a filled-in form is how a sale gets abandoned.
|
||||
{
|
||||
const auto rejected = srv.Post("/shop/fp6-pmos",
|
||||
"email=bad&name=Ada&street=Main%201&postal=1234AB&city=Delft&country=NLD");
|
||||
for (std::string_view probe : { "value=\"bad\"", "value=\"NLD\"", "value=\"Ada\"",
|
||||
"value=\"Main 1\"", "value=\"Delft\"" }) {
|
||||
Check(rejected.body.find(probe) != std::string::npos,
|
||||
std::format("rejected form preserves {}", probe));
|
||||
}
|
||||
Check(rejected.body.find("field__error") != std::string::npos,
|
||||
"rejected form shows a field error");
|
||||
}
|
||||
// A refused destination says why, in the form, with the address still in
|
||||
// it — the visitor should learn the shop does not sell there, not that
|
||||
// something went wrong.
|
||||
{
|
||||
const auto refused = srv.Post("/shop/fp6-pmos",
|
||||
"email=us%40example.org&name=Pat&street=1%20Main%20St&postal=43004&city=Columbus&country=US");
|
||||
Check(refused.body.find("does not sell or ship to the United States or Canada")
|
||||
!= std::string::npos,
|
||||
"refusal explains itself on the form");
|
||||
Check(refused.body.find("value=\"Pat\"") != std::string::npos,
|
||||
"a refused submission keeps what was typed");
|
||||
}
|
||||
// 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
|
||||
// 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", ""x":["US","CA"]",
|
||||
"total preview knows the refused 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
|
||||
// trap, re-armed.
|
||||
{
|
||||
const auto pot = srv.Post("/shop/fp6-pmos", Good("&website=x"));
|
||||
std::smatch m;
|
||||
Check(std::regex_search(pot.body, m, std::regex(R"lit(notice--error">([^<]*))lit")),
|
||||
"honeypot rejection renders an error notice");
|
||||
if (!m.empty()) {
|
||||
std::string notice = m[1].str();
|
||||
for (char& c : notice) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
|
||||
const bool names = notice.find("honeypot") != std::string::npos
|
||||
|| notice.find("website") != std::string::npos
|
||||
|| notice.find("hidden") != std::string::npos
|
||||
|| notice.find("trap") != std::string::npos;
|
||||
Check(!names, "honeypot failure does not name the trap", m[1].str());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
ServerOptions options;
|
||||
options.gpg = true;
|
||||
options.mailer = true;
|
||||
TestServer srv(argv[1], 8217, options);
|
||||
|
||||
if (srv.ShopOpen()) {
|
||||
OpenShopLifecycle(srv);
|
||||
} else {
|
||||
ComingSoon(srv);
|
||||
}
|
||||
|
||||
AlwaysOnValidation(srv);
|
||||
|
||||
if (srv.ShopOpen()) {
|
||||
RejectedFormEcho(srv);
|
||||
}
|
||||
|
||||
return Finish();
|
||||
}
|
||||
273
tests/ShouldPublishFinancials/main.cpp
Normal file
273
tests/ShouldPublishFinancials/main.cpp
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
/*
|
||||
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 financials page and the bunq mutation ingest behind it. The callback is
|
||||
// the only path by which a stranger's money reaches a public number on this
|
||||
// site, so its parser, its classifier and above all its default-deny
|
||||
// behaviour are pinned here. A rule that accidentally claims everything, or a
|
||||
// classifier that treats an unrecognised transfer as a donation, would
|
||||
// publish a figure that is simply untrue.
|
||||
|
||||
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 financials page ───────────────────────────────────────────────
|
||||
void FinancialsPage() {
|
||||
const Financials fin = LoadFinancials(
|
||||
R"({"as_of":"2026-08-14",)"
|
||||
R"("donations":{"count":3,"total_minor":4500},)"
|
||||
R"("expenses":[{"label":"Hosting","total_minor":1200},)"
|
||||
R"({"label":"Insurance","total_minor":3600},)"
|
||||
R"({"label":"Inventory","total_minor":230000}]})");
|
||||
Check(fin.Loaded(), "financials: loads");
|
||||
Check(fin.donationCount == 3 && fin.donationsMinor == 4500,
|
||||
"financials: donations aggregate");
|
||||
Check(fin.expenses.size() == 3 && fin.expenses[0].label == "Hosting"
|
||||
&& fin.expenses[1].totalMinor == 3600
|
||||
&& fin.expenses[2].label == "Inventory",
|
||||
"financials: expense categories in file order");
|
||||
Check(fin.ExpensesMinor() == 234800, "financials: expense total");
|
||||
Check(!LoadFinancials("garbage").Loaded(),
|
||||
"financials: malformed input yields none");
|
||||
Check(!LoadFinancials(R"({"donations":{"count":1,"total_minor":1}})").Loaded(),
|
||||
"financials: undated figures stay unpublished");
|
||||
Check(LoadFinancials(R"({"as_of":"2026-08-14","expenses":[{"total_minor":5}]})")
|
||||
.expenses.empty(),
|
||||
"financials: a category without a label is dropped");
|
||||
|
||||
Check(ParseRoute("/financials").kind == RouteKind::Financials,
|
||||
"route: /financials");
|
||||
Check(ParseRoute("/financials/").kind == RouteKind::Financials,
|
||||
"route: /financials/ normalises");
|
||||
bool inSitemap = false;
|
||||
for (std::string_view p : SitemapPaths()) inSitemap = inSitemap || p == "/financials";
|
||||
Check(inSitemap, "route: /financials is in the sitemap");
|
||||
|
||||
const LegalPage& notes = Content::FinancialsPage();
|
||||
Check(notes.slug == "financials" && !notes.lede.empty()
|
||||
&& notes.sections.size() >= 2,
|
||||
"content: financials notes present");
|
||||
// The PROMISE, not the wording that happens to carry it. Pinning a
|
||||
// phrase in the lede made rewriting the page's opening sentence a
|
||||
// test failure, which is backwards: the lede is voice, the promise
|
||||
// below is the commitment that must survive every edit.
|
||||
bool statesPromise = false;
|
||||
for (const LegalSection& sec : notes.sections) {
|
||||
for (const std::string& para : sec.body) {
|
||||
if (para.find("No individual transactions") != std::string::npos) {
|
||||
statesPromise = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
Check(statesPromise, "content: financials page states what it never publishes");
|
||||
|
||||
// The rendered page: live sales plus the bank aggregates, with the
|
||||
// machine-readable copy the e2e suite reads.
|
||||
const Views::RenderedPage fp = Views::RenderFinancials(2, 113745, fin);
|
||||
Check(fp.status == 200, "financials: renders");
|
||||
Check(fp.main.View().find("data-fin-sales-minor=\"113745\"") != std::string_view::npos
|
||||
&& fp.main.View().find("data-fin-expenses-minor=\"234800\"")
|
||||
!= std::string_view::npos,
|
||||
"financials: machine-readable totals");
|
||||
Check(fp.main.View().find("€1137.45") != std::string_view::npos
|
||||
&& fp.main.View().find("€1182.45") != std::string_view::npos,
|
||||
"financials: income rows and their total render");
|
||||
Check(fp.main.View().find("Hosting") != std::string_view::npos
|
||||
&& fp.main.View().find("€2348") != std::string_view::npos,
|
||||
"financials: expense categories and their total render");
|
||||
// Net = income - expenses = (4500 + 113745) - 234800 = -116555.
|
||||
// Deliberately a NEGATIVE case: a shop that has just bought stock is
|
||||
// the normal way for this figure to go below zero, and "€-1165.55" is
|
||||
// what must render rather than a mangled or unsigned number.
|
||||
Check(fp.main.View().find("data-fin-net-minor=\"-116555\"") != std::string_view::npos
|
||||
&& fp.main.View().find("€-1165.55") != std::string_view::npos,
|
||||
"financials: net renders, and renders negative honestly");
|
||||
Check(Money::FormatEuro(-26260) == "€-262.60" && Money::FormatEuro(-500) == "€-5.00",
|
||||
"financials: negative euro formatting");
|
||||
|
||||
// Before the bank figures exist the page says so instead of lying
|
||||
// with zeros — and publishes no donation figures at all.
|
||||
const Views::RenderedPage bare = Views::RenderFinancials(0, 0, Financials{});
|
||||
Check(bare.main.View().find("data-fin-sales-count=\"0\"") != std::string_view::npos
|
||||
&& bare.main.View().find("not been published yet") != std::string_view::npos
|
||||
&& bare.main.View().find("data-fin-donations-count") == std::string_view::npos,
|
||||
"financials: unpublished bank figures say so and publish nothing");
|
||||
// And no net either: income minus an unknown expense side is not a
|
||||
// net of anything, and printing sales there would read as a company
|
||||
// with no costs.
|
||||
Check(bare.main.View().find("data-fin-net-minor") == std::string_view::npos
|
||||
&& bare.main.View().find(">Net<") == std::string_view::npos,
|
||||
"financials: no net figure while expenses are unpublished");
|
||||
|
||||
// Lifetime sales: ever-paid counts, awaiting doesn't, a refund after
|
||||
// payment stays counted, a hand-shipped legacy order counts too.
|
||||
Server::OrderRecord paid;
|
||||
paid.totalMinor = 56330;
|
||||
paid.paidAt = "2026-08-14T00:00:00Z";
|
||||
paid.status = "paid";
|
||||
Server::OrderRecord waiting;
|
||||
waiting.totalMinor = 99999;
|
||||
Server::OrderRecord refunded;
|
||||
refunded.totalMinor = 56930;
|
||||
refunded.paidAt = "2026-08-14T00:00:00Z";
|
||||
refunded.status = "cancelled";
|
||||
Server::OrderRecord shipped;
|
||||
shipped.totalMinor = 200;
|
||||
shipped.status = "shipped";
|
||||
const std::array<Server::OrderRecord, 4> orders{ paid, waiting, refunded, shipped };
|
||||
const Server::SalesSummary sum = Server::SummarizeSales(orders);
|
||||
Check(sum.count == 3 && sum.totalMinor == 56330 + 56930 + 200,
|
||||
"financials: sales count ever-paid orders only");
|
||||
Check(Server::SummarizeSales({}).count == 0,
|
||||
"financials: empty ledger sums to zero");
|
||||
}
|
||||
|
||||
// ── the bunq mutation callback ────────────────────────────────────────
|
||||
void BunqIngest() {
|
||||
using Server::ParseSignedAmountToMinor;
|
||||
Check(ParseSignedAmountToMinor("25.00") == 2500, "bunq: positive amount");
|
||||
Check(ParseSignedAmountToMinor("-12.50") == -1250, "bunq: outgoing is negative");
|
||||
Check(ParseSignedAmountToMinor("+5") == 500, "bunq: explicit plus");
|
||||
Check(!ParseSignedAmountToMinor("1.234").has_value(), "bunq: too many decimals");
|
||||
Check(!ParseSignedAmountToMinor("nonsense").has_value(), "bunq: non-numeric");
|
||||
Check(!ParseSignedAmountToMinor("").has_value(), "bunq: empty amount");
|
||||
|
||||
// A realistic payload: the mutation is nested two wrappers deep, and
|
||||
// the parser finds it by SHAPE so a wrapper rename cannot silently
|
||||
// turn every callback into a no-op.
|
||||
constexpr std::string_view kPayload =
|
||||
R"({"NotificationUrl":{"target_url":"https://catcrafts.net/api/bunq/s",)"
|
||||
R"("category":"MUTATION","event_type":"MUTATION_CREATED","object":{"Payment":{)"
|
||||
R"("id":4823,"created":"2026-08-14 09:31:02.123456","monetary_account_id":9911,)"
|
||||
R"("amount":{"currency":"EUR","value":"25.00"},)"
|
||||
R"("description":"Thanks for imsd!",)"
|
||||
R"("counterparty_alias":{"iban":"NL55BUNQ2025123456","display_name":"A Donor"}}}}})";
|
||||
const auto m = Server::ParseBunqMutation(kPayload);
|
||||
Check(m.has_value(), "bunq: nested payload parses");
|
||||
if (m) {
|
||||
Check(m->id == "4823", "bunq: numeric id travels as text");
|
||||
Check(m->amountMinor == 2500 && m->currency == "EUR", "bunq: amount and currency");
|
||||
Check(m->account == "9911", "bunq: monetary account");
|
||||
Check(m->counterpartyIban == "NL55BUNQ2025123456", "bunq: counterparty iban");
|
||||
// The time of day never survives the parser: an exact timestamp
|
||||
// is the one field that would let a watcher pin a donation to a
|
||||
// person who mentioned donating.
|
||||
Check(m->created == "2026-08-14", "bunq: only the date is kept");
|
||||
}
|
||||
Check(!Server::ParseBunqMutation("garbage").has_value(), "bunq: malformed payload");
|
||||
Check(!Server::ParseBunqMutation(R"({"NotificationUrl":{"category":"MUTATION"}})")
|
||||
.has_value(),
|
||||
"bunq: a notification with no mutation yields nothing");
|
||||
|
||||
const Server::FinancialRules rules = Server::LoadFinancialRules(
|
||||
R"({"donation_accounts":[9911],)"
|
||||
R"("rules":[)"
|
||||
R"({"iban":"NL01OWNSELF0000000","group":"ignore"},)"
|
||||
R"({"description_contains":"hetzner","group":"expense","label":"Hosting"},)"
|
||||
R"({"iban":"DE02SUPPLIER000000","group":"expense","label":"Inventory"},)"
|
||||
R"({"group":"expense","label":"Claims everything"},)"
|
||||
R"({"iban":"NL03TYPO0000000000","group":"nonsense","label":"X"},)"
|
||||
R"({"iban":"NL04NOLABEL0000000","group":"expense"}]})");
|
||||
Check(rules.donationAccounts.size() == 1 && rules.donationAccounts[0] == "9911",
|
||||
"bunq: numeric donation account loads as text");
|
||||
// Three of the six survive: the criterion-less rule would claim every
|
||||
// mutation, the typo'd group is not a category, and an expense with
|
||||
// no label has nothing to render as.
|
||||
Check(rules.rules.size() == 3, "bunq: unsafe rules are dropped at load");
|
||||
|
||||
// Incoming on the donation account, claimed by no explicit rule.
|
||||
Check(m && Server::ClassifyMutation(*m, rules).group == "donations",
|
||||
"bunq: incoming on the donation account is a donation");
|
||||
|
||||
Server::BankMutation x = *m;
|
||||
// Money LEAVING the donation account is not a gift to this company.
|
||||
x.amountMinor = -2500;
|
||||
Check(Server::ClassifyMutation(x, rules).group.empty(),
|
||||
"bunq: outgoing on the donation account is not a donation");
|
||||
// An explicit ignore beats the donation-account default, which is how
|
||||
// the owner's own transfer between accounts stays out of the total.
|
||||
x = *m;
|
||||
x.counterpartyIban = "nl01ownself0000000";
|
||||
Check(Server::ClassifyMutation(x, rules).group == "ignore",
|
||||
"bunq: an explicit rule beats the donation default, case-insensitively");
|
||||
// Foreign currency is never folded into a euro total.
|
||||
x = *m;
|
||||
x.currency = "USD";
|
||||
Check(Server::ClassifyMutation(x, rules).group.empty(),
|
||||
"bunq: non-euro is never counted");
|
||||
// Default-deny: an ordinary transfer from a stranger, on an account
|
||||
// that is not the donation one, is withheld rather than guessed at.
|
||||
x = *m;
|
||||
x.account = "1234";
|
||||
x.counterpartyIban = "NL99UNKNOWN0000000";
|
||||
x.description = "";
|
||||
Check(Server::ClassifyMutation(x, rules).group.empty(),
|
||||
"bunq: an unmatched mutation is withheld, not guessed");
|
||||
|
||||
Server::BankMutation bill;
|
||||
bill.currency = "EUR";
|
||||
bill.amountMinor = -1200;
|
||||
bill.description = "HETZNER ONLINE GMBH invoice";
|
||||
bill.created = "2026-08-15";
|
||||
const Server::MutationClass billClass = Server::ClassifyMutation(bill, rules);
|
||||
Check(billClass.group == "expense" && billClass.label == "Hosting",
|
||||
"bunq: description matching, case-insensitively");
|
||||
|
||||
// Folding into the aggregates.
|
||||
Financials fin;
|
||||
Server::ApplyMutation(fin, Server::ClassifyMutation(*m, rules), *m);
|
||||
Check(fin.donationCount == 1 && fin.donationsMinor == 2500,
|
||||
"bunq: a donation moves the count and the total");
|
||||
Check(fin.asOf == "2026-08-14", "bunq: as-of follows the mutation date");
|
||||
Server::ApplyMutation(fin, billClass, bill);
|
||||
Check(fin.expenses.size() == 1 && fin.expenses[0].label == "Hosting"
|
||||
&& fin.expenses[0].totalMinor == 1200,
|
||||
"bunq: an outgoing bill becomes a positive expense");
|
||||
Check(fin.asOf == "2026-08-15", "bunq: as-of advances");
|
||||
// A supplier refund reduces the category rather than appearing as
|
||||
// income, and never drags the as-of date backwards.
|
||||
Server::BankMutation refund = bill;
|
||||
refund.amountMinor = 500;
|
||||
refund.created = "2026-08-01";
|
||||
Server::ApplyMutation(fin, billClass, refund);
|
||||
Check(fin.expenses[0].totalMinor == 700, "bunq: a refund reduces its category");
|
||||
Check(fin.asOf == "2026-08-15", "bunq: as-of never moves backwards");
|
||||
// An unclassified mutation touches nothing at all.
|
||||
const Financials before = fin;
|
||||
Server::ApplyMutation(fin, Server::MutationClass{}, *m);
|
||||
Check(fin.donationCount == before.donationCount
|
||||
&& fin.ExpensesMinor() == before.ExpensesMinor(),
|
||||
"bunq: an unclassified mutation changes no total");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
FinancialsPage();
|
||||
BunqIngest();
|
||||
|
||||
if (failures != 0) {
|
||||
std::println(std::cerr, "{} check(s) failed", failures);
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
182
tests/ShouldRenderMarkdown/main.cpp
Normal file
182
tests/ShouldRenderMarkdown/main.cpp
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
/*
|
||||
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 Markdown renderer, which is the newest place untrusted text becomes
|
||||
// markup — post bodies are fetched from someone else's server, so every one of
|
||||
// these assertions is ultimately about the same thing: nothing in a body can
|
||||
// escape into the document. The structural cases are here too, because a parser
|
||||
// that silently drops a construct loses content invisibly.
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
void CheckEq(const Html::SafeHtml& actual, std::string_view expected, std::string_view what) {
|
||||
Check(actual.View() == expected, what, actual.View());
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
auto md = [](std::string_view text,
|
||||
std::span<const PostMedia> media = {}) {
|
||||
return Markdown::Render(text, media);
|
||||
};
|
||||
|
||||
// ── the guarantee ─────────────────────────────────────────────────
|
||||
CheckEq(md("<script>alert(1)</script>"),
|
||||
"<p><script>alert(1)</script></p>", "md: html is text, never markup");
|
||||
CheckEq(md(")"),
|
||||
R"(<div class="post-media"><img class="post-media__item" loading="lazy" )"
|
||||
R"(decoding="async" alt="x" src="#"></div>)",
|
||||
"md: javascript: image source neutralised");
|
||||
CheckEq(md("[x](javascript:alert(1))"),
|
||||
R"(<p><a href="#" rel="noopener">x</a></p>)",
|
||||
"md: javascript: link neutralised");
|
||||
CheckEq(md("a \" b & c"), "<p>a " b & c</p>", "md: quotes and ampersands escaped");
|
||||
// A code span is verbatim text, and verbatim is exactly where an escaper
|
||||
// is most often forgotten.
|
||||
CheckEq(md("`<b>`"), "<p><code><b></code></p>", "md: code span escaped");
|
||||
|
||||
// ── blocks ────────────────────────────────────────────────────────
|
||||
CheckEq(md(""), "", "md: empty body renders nothing");
|
||||
CheckEq(md("plain text"), "<p>plain text</p>", "md: paragraph");
|
||||
// Demotion by one: the page h1 is the post title, so a body's own top-level
|
||||
// heading is a section within it.
|
||||
CheckEq(md("# Heading"), "<h2>Heading</h2>", "md: h1 demoted to h2");
|
||||
CheckEq(md("### Heading"), "<h4>Heading</h4>", "md: h3 demoted to h4");
|
||||
CheckEq(md("#nothashtag"), "<p>#nothashtag</p>", "md: # without a space is not a heading");
|
||||
CheckEq(md("> quoted"),
|
||||
R"(<blockquote class="post-body__quote"><p>quoted</p></blockquote>)",
|
||||
"md: blockquote");
|
||||
// The quoted lines are re-parsed, so a multi-paragraph quote keeps its
|
||||
// paragraphs instead of collapsing into one run-on line.
|
||||
CheckEq(md("> one\n>\n> two"),
|
||||
R"(<blockquote class="post-body__quote"><p>one</p><p>two</p></blockquote>)",
|
||||
"md: blockquote keeps its paragraphs");
|
||||
CheckEq(md("- a\n- b"),
|
||||
R"(<ul class="post-body__list"><li>a</li><li>b</li></ul>)", "md: unordered list");
|
||||
CheckEq(md("1. a\n2. b"),
|
||||
R"(<ol class="post-body__list"><li>a</li><li>b</li></ol>)", "md: ordered list");
|
||||
// A list resumed after an interrupting paragraph continues its numbering.
|
||||
// Without the start attribute the mini-guide in one of these posts renders
|
||||
// as steps 1-4 followed by steps 1, 2, 3.
|
||||
CheckEq(md("5. e"),
|
||||
R"(<ol class="post-body__list" start="5"><li>e</li></ol>)",
|
||||
"md: ordered list keeps the number it announced");
|
||||
// Blank lines between items are spacing, not seven one-item lists.
|
||||
CheckEq(md("1. a\n\n2. b"),
|
||||
R"(<ol class="post-body__list"><li>a</li><li>b</li></ol>)",
|
||||
"md: blank line inside a list does not split it");
|
||||
CheckEq(md("---"), "<hr>", "md: thematic break");
|
||||
CheckEq(md("- - -"), "<hr>", "md: spaced rule is not a one-item list");
|
||||
// Whitespace in pasted terminal output is the content.
|
||||
CheckEq(md("```\n a\tb\n```"),
|
||||
"<pre class=\"post-body__code\"><code> a\tb\n</code></pre>",
|
||||
"md: fenced code is verbatim");
|
||||
// An unterminated fence must not swallow the document into nothing.
|
||||
Check(md("```\nx").View().find("<code>x") != std::string_view::npos,
|
||||
"md: unterminated fence still renders its content");
|
||||
|
||||
// ── inline ────────────────────────────────────────────────────────
|
||||
CheckEq(md("**bold**"), "<p><strong>bold</strong></p>", "md: strong");
|
||||
CheckEq(md("*em*"), "<p><em>em</em></p>", "md: emphasis");
|
||||
CheckEq(md("2 * 3 * 4"), "<p>2 * 3 * 4</p>", "md: spaced asterisks stay literal");
|
||||
// Underscores are deliberately inert: these posts paste kernel symbol
|
||||
// names into prose, and italicising half of one is worse than not
|
||||
// italicising a word that used the underscore form.
|
||||
CheckEq(md("kworker/u16:8-qc_ufs_qos_swq"),
|
||||
"<p>kworker/u16:8-qc_ufs_qos_swq</p>", "md: underscores are not emphasis");
|
||||
CheckEq(md("\\*literal\\*"), "<p>*literal*</p>", "md: backslash escape");
|
||||
CheckEq(md("[label](https://x.example/y)"),
|
||||
R"(<p><a href="https://x.example/y" rel="noopener">label</a></p>)", "md: link");
|
||||
// Bare addresses are pasted constantly in these posts; leaving them inert
|
||||
// would strip most of the outbound value out of the page.
|
||||
CheckEq(md("see https://x.example/y"),
|
||||
R"(<p>see <a href="https://x.example/y">https://x.example/y</a></p>)",
|
||||
"md: bare URL autolinked");
|
||||
|
||||
// ── embedded media ────────────────────────────────────────────────
|
||||
// A paragraph that is nothing but images becomes the same media block the
|
||||
// cards use, rather than a <p> of pictures.
|
||||
CheckEq(md(""),
|
||||
R"(<div class="post-media"><img class="post-media__item" loading="lazy" )"
|
||||
R"(decoding="async" alt="a" src="/media/x.webp"></div>)",
|
||||
"md: image-only paragraph is a media block");
|
||||
Check(md("text ").View().starts_with("<p>text <img"),
|
||||
"md: an image inside a sentence stays inline");
|
||||
|
||||
// Dimensions come from the sidecar list, because Markdown syntax has
|
||||
// nowhere to carry them — and without them the prose below every
|
||||
// screenshot jumps as the file arrives.
|
||||
{
|
||||
std::vector<PostMedia> media;
|
||||
PostMedia img;
|
||||
img.src = "/media/x.webp";
|
||||
img.kind = "image";
|
||||
img.avif = "/media/x.avif";
|
||||
img.fallback = "/media/x.png";
|
||||
img.width = 800;
|
||||
img.height = 600;
|
||||
media.push_back(img);
|
||||
|
||||
PostMedia vid;
|
||||
vid.src = "/media/v.mp4";
|
||||
vid.kind = "video";
|
||||
vid.poster = "/media/v.poster.webp";
|
||||
vid.fallback = "/media/v.h264.mp4";
|
||||
vid.width = 1080;
|
||||
vid.height = 1920;
|
||||
media.push_back(vid);
|
||||
|
||||
const auto out = md("", media);
|
||||
Check(out.View().find(R"(width="800" height="600")") != std::string_view::npos,
|
||||
"md: inline image carries its dimensions", out.View());
|
||||
// Routed through :Media, so a body image gets the same format ladder a
|
||||
// card image does rather than a second, plainer implementation.
|
||||
Check(out.View().find(R"(<source srcset="/media/x.avif" type="image/avif">)")
|
||||
!= std::string_view::npos
|
||||
&& out.View().find(R"(src="/media/x.png")") != std::string_view::npos,
|
||||
"md: inline image gets the avif/png ladder", out.View());
|
||||
|
||||
// An inline video gets the same treatment a headline one does,
|
||||
// fallback source and all.
|
||||
const auto vout = md("", media);
|
||||
Check(vout.View().find(R"(poster="/media/v.poster.webp")") != std::string_view::npos
|
||||
&& vout.View().find("codecs=av01") != std::string_view::npos
|
||||
&& vout.View().find(R"(<source src="/media/v.h264.mp4")") != std::string_view::npos,
|
||||
"md: inline video gets poster and H.264 fallback", vout.View());
|
||||
}
|
||||
|
||||
// ── termination ───────────────────────────────────────────────────
|
||||
// Unbalanced delimiters are the classic way to hang a hand-written
|
||||
// parser, and a body is input from someone else's server.
|
||||
Check(!md("**unclosed").View().empty(), "md: unclosed strong terminates");
|
||||
Check(!md("[unclosed](").View().empty(), "md: unclosed link terminates");
|
||||
Check(!md(".View().empty(), "md: unclosed image terminates");
|
||||
Check(!md("`unclosed").View().empty(), "md: unclosed code span terminates");
|
||||
Check(!md("> > > > > > > > deep").View().empty(), "md: over-deep nesting terminates");
|
||||
|
||||
if (failures != 0) {
|
||||
std::println(std::cerr, "{} check(s) failed", failures);
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
76
tests/ShouldSellTheShopFront/main.cpp
Normal file
76
tests/ShouldSellTheShopFront/main.cpp
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
/*
|
||||
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 shop front: prices rendered from the same integers the checkout
|
||||
// charges, the currency conversions the shop card carries, and the copy
|
||||
// around them. Runs in both shop states — coming-soon asserts the closed
|
||||
// shape, launch re-arms the open one with no edit here.
|
||||
|
||||
import std;
|
||||
import Catcrafts.E2eHarness;
|
||||
|
||||
using namespace Catcrafts::E2e;
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
TestServer srv(argv[1], 8216);
|
||||
|
||||
// The price is rendered from the same integers the checkout charges, with
|
||||
// the derived ex-VAT twin alongside — asserting both pins the arithmetic.
|
||||
srv.BodyHas("/shop/fp6-pmos", "€563.30",
|
||||
"product page shows the from-price (green supplier + €50)");
|
||||
srv.BodyHas("/shop/fp6-pmos", "€465.54", "product page shows the derived ex-VAT price");
|
||||
srv.BodyHas("/shop/fp6-pmos", ">from<", "product page marks the price as a from-price");
|
||||
srv.BodyHas("/shop", "€563.30", "shop card shows the from-price");
|
||||
// Every colour is priced in the selector, and the form carries the exact
|
||||
// data blob the preview computes from.
|
||||
srv.BodyHas("/shop/fp6-pmos", "Black — €569.30", "colour selector prices black");
|
||||
srv.BodyHas("/shop/fp6-pmos", "White — €654.88", "colour selector prices white");
|
||||
if (srv.ShopOpen()) {
|
||||
srv.BodyHas("/shop/fp6-pmos", "data-cc=", "form embeds the pricing blob");
|
||||
srv.BodyHas("/shop/fp6-pmos", "id=\"cc-total\"", "live total element present");
|
||||
} else {
|
||||
srv.BodyHas("/shop/fp6-pmos", "Coming soon", "coming-soon notice on the buy panel");
|
||||
srv.BodyHas("/shop", "coming soon", "shop card carries the coming-soon badge");
|
||||
srv.BodyLacks("/shop/fp6-pmos", "<form", "no order form while coming soon");
|
||||
}
|
||||
srv.BodyHas("/shop/fp6-pmos", "src=\"/fp6-pmos.jpg\"", "product page embeds the photo");
|
||||
srv.BodyHas("/shop", "src=\"/fp6-pmos.jpg\"", "shop card embeds the thumbnail");
|
||||
// The image file itself is Caddy's to serve (static asset), so its
|
||||
// presence is asserted against the repo, not this server.
|
||||
Check(std::filesystem::exists("images/fp6-pmos.jpg"), "product photo exists in the repo");
|
||||
srv.BodyHas("/shop/fp6-pmos", "not yet verified", "emergency-calling caveat is on the page");
|
||||
srv.BodyLacks("/shop", "reservation", "no reservation copy survives on /shop");
|
||||
srv.BodyLacks("/shop/fp6-pmos", "Reserve one", "no reservation form survives");
|
||||
|
||||
// The shop card: one euro number as the crawler/no-JS text, every
|
||||
// supported currency pre-formatted server-side as a data attribute for
|
||||
// the script to pick from. Converted amounts carry "~". GBP converts the
|
||||
// ex-VAT price; SEK (an EU member's currency) converts the VAT-inclusive
|
||||
// price. USD and CAD are absent on purpose — the shop refuses those
|
||||
// destinations, so it does not quote a local price to them either.
|
||||
srv.BodyHas("/shop", "class=\"price__single\"", "shop card renders the single-number price");
|
||||
srv.BodyHas("/shop", "data-gbp=\"~£", "shop card carries a GBP conversion");
|
||||
srv.BodyHas("/shop", "data-sek=\"~kr ", "shop card carries an SEK conversion");
|
||||
srv.BodyHas("/shop", "data-world=\"€465.54\"", "shop card carries the euro export fallback");
|
||||
srv.BodyLacks("/shop", "data-usd=", "no USD price for a country the shop refuses");
|
||||
srv.BodyLacks("/shop", "data-cad=", "no CAD price for a country the shop refuses");
|
||||
// The product page gets the same headline element, so a British visitor
|
||||
// sees ~£ at the top there too, and the buy card states the customs
|
||||
// position plainly.
|
||||
srv.BodyHas("/shop/fp6-pmos", "data-gbp=\"~£", "product page headline carries the conversion");
|
||||
srv.BodyHas("/shop/fp6-pmos", "indicative only",
|
||||
"buy card says converted prices are indicative");
|
||||
srv.BodyHas("/shop/fp6-pmos", "customs authority",
|
||||
"buy card names whose problem import charges are");
|
||||
srv.BodyLacks("/shop/fp6-pmos", "collected on arrival",
|
||||
"the vague customs phrasing is gone");
|
||||
// The label must not claim the Dutch rate is an EU-wide one.
|
||||
srv.BodyLacks("/shop/fp6-pmos", "EU VAT", "price label does not call 21% an EU-wide rate");
|
||||
|
||||
return Finish();
|
||||
}
|
||||
183
tests/ShouldServeFinancialsLive/main.cpp
Normal file
183
tests/ShouldServeFinancialsLive/main.cpp
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
/*
|
||||
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 financials page over real HTTP, and the bunq mutation callback that
|
||||
// feeds it. Liveness is the page's promise: the aggregates file appears and
|
||||
// the very next request reflects it — no restart, no cache, no delay. The
|
||||
// callback is the only path by which a stranger's money reaches a public
|
||||
// number, so idempotency, default-deny, and the no-leak guarantees are pinned
|
||||
// against the real endpoint here.
|
||||
|
||||
import std;
|
||||
import Catcrafts.E2eHarness;
|
||||
|
||||
using namespace Catcrafts::E2e;
|
||||
|
||||
namespace {
|
||||
|
||||
// fin_attr <attribute> -> its value on the live page
|
||||
std::string FinAttr(TestServer& srv, std::string_view attr) {
|
||||
const std::string body = srv.Body("/financials");
|
||||
std::smatch m;
|
||||
if (std::regex_search(body, m, std::regex(std::format(R"lit({}="([0-9]*)")lit", attr)))) {
|
||||
return m[1].str();
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
std::string BunqPayload(std::string_view id, std::string_view account,
|
||||
std::string_view value, std::string_view iban,
|
||||
std::string_view description) {
|
||||
std::string p = R"({"NotificationUrl":{"category":"MUTATION","event_type":"MUTATION_CREATED","object":{"Payment":{"id":)";
|
||||
p += id;
|
||||
p += R"(,"created":"2026-08-15 09:31:02.000000","monetary_account_id":)";
|
||||
p += account;
|
||||
p += R"(,"amount":{"currency":"EUR","value":")";
|
||||
p += value;
|
||||
p += R"("},"description":")";
|
||||
p += description;
|
||||
p += R"(","counterparty_alias":{"iban":")";
|
||||
p += iban;
|
||||
p += R"(","display_name":"Someone"}}}}})";
|
||||
return p;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
// The secret IS the last segment of the callback URL, and setting it is
|
||||
// what brings the endpoint into existence — unset, the path is an
|
||||
// ordinary 404. Note what is NOT here: a bunq API key. One could initiate
|
||||
// payments, so no such key ever reaches the server; it only receives.
|
||||
constexpr std::string_view kSecret = "e2e-callback-secret-not-a-real-one";
|
||||
ServerOptions options;
|
||||
options.env.emplace_back("BUNQ_CALLBACK_SECRET", std::string(kSecret));
|
||||
TestServer srv(argv[1], 8215, options);
|
||||
|
||||
// ── the financials page ───────────────────────────────────────────
|
||||
// Aggregate-only by construction: totals and counts, machine-readable via
|
||||
// the data-fin-* attributes. Live is the page's promise, so it must never
|
||||
// sit in a shared cache.
|
||||
srv.HeaderHas("/financials", "cache-control", "no-store", "financials are never cached");
|
||||
srv.BodyHas("/financials", "data-fin-sales-count=\"0\"", "financials start at zero sales");
|
||||
// Before the bank-aggregates file exists the page says so, and publishes
|
||||
// no donation figures at all — an unknowable €0 would be a lie.
|
||||
srv.BodyHas("/financials", "not been published yet", "unpublished bank figures say so");
|
||||
srv.BodyLacks("/financials", "data-fin-donations-count",
|
||||
"no donation figures before the file exists");
|
||||
|
||||
// The aggregates file appears, exactly as the owner's tooling will write
|
||||
// it, and the very next request reflects it — this is the liveness the
|
||||
// donation counter depends on.
|
||||
WriteFile(std::filesystem::path(srv.Orders().string() + ".financials.json"),
|
||||
R"({"as_of":"2026-08-14",)"
|
||||
"\n"
|
||||
R"( "donations":{"count":3,"total_minor":4500},)"
|
||||
"\n"
|
||||
R"( "expenses":[{"label":"Hosting","total_minor":1200},)"
|
||||
"\n"
|
||||
R"( {"label":"Inventory","total_minor":230000}]})"
|
||||
"\n");
|
||||
srv.BodyHas("/financials", "data-fin-donations-count=\"3\"", "donation count picked up live");
|
||||
srv.BodyHas("/financials", "data-fin-expenses-minor=\"231200\"",
|
||||
"expense total picked up live");
|
||||
// Net = (donations 4500 + sales 0) - expenses 231200. Negative on
|
||||
// purpose: a shop that has bought stock but not sold it is exactly this
|
||||
// shape, and the figure has to survive going below zero.
|
||||
srv.BodyHas("/financials", "data-fin-net-minor=\"-226700\"",
|
||||
"net is published and may be negative");
|
||||
srv.BodyHas("/financials", "€-2267", "a negative net renders with its sign");
|
||||
srv.BodyHas("/financials", "Hosting", "an expense category renders");
|
||||
srv.BodyHas("/financials", "Inventory", "a second expense category renders");
|
||||
srv.BodyHas("/financials", "2026-08-14", "bank figures carry their as-of date");
|
||||
|
||||
// ── the bunq mutation callback ────────────────────────────────────
|
||||
// The rules are written here rather than at startup on purpose — they are
|
||||
// re-read per callback, so a new rule takes effect without a restart.
|
||||
WriteFile(std::filesystem::path(srv.Orders().string() + ".financial-rules.json"),
|
||||
R"({"donation_accounts":[9911],)"
|
||||
"\n"
|
||||
R"( "rules":[{"description_contains":"hetzner","group":"expense","label":"Hosting"},)"
|
||||
"\n"
|
||||
R"( {"iban":"NL01OWNSELF0000000","group":"ignore"}]})"
|
||||
"\n");
|
||||
|
||||
const std::string cb = std::format("/api/bunq/{}", kSecret);
|
||||
auto bunqPost = [&](std::string_view id, std::string_view account,
|
||||
std::string_view value, std::string_view iban,
|
||||
std::string_view description) {
|
||||
return srv.Post(cb, BunqPayload(id, account, value, iban, description),
|
||||
"application/json").status;
|
||||
};
|
||||
|
||||
// An endpoint guarded by a secret must not confirm its own existence:
|
||||
// every unauthorised shape is the same 404 an unknown order token gets.
|
||||
{
|
||||
const auto wrong = srv.Post("/api/bunq/wrong-secret", "{}", "application/json");
|
||||
Check(wrong.status == "404", "POST /api/bunq/wrong-secret -> 404", wrong.status);
|
||||
}
|
||||
srv.CheckStatus(cb, "404"); // GET on the right URL is still not a callback
|
||||
srv.CheckStatus(cb, "404", "HEAD");
|
||||
|
||||
// A donation arrives on the donation account. No rule names the sender —
|
||||
// donors are strangers, which is exactly why the account is what
|
||||
// classifies.
|
||||
Check(bunqPost("4823", "9911", "25.00", "NL55BUNQ2025123456", "Thanks for imsd") == "200",
|
||||
"the callback accepts a mutation");
|
||||
Check(FinAttr(srv, "data-fin-donations-count") == "4"
|
||||
&& FinAttr(srv, "data-fin-donations-minor") == "7000",
|
||||
"a donation ticks the public counter immediately");
|
||||
|
||||
// bunq redelivers a callback it did not see a 2xx for, and can redeliver
|
||||
// one it did. Counting that twice would publish money that never arrived.
|
||||
bunqPost("4823", "9911", "25.00", "NL55BUNQ2025123456", "Thanks for imsd");
|
||||
Check(FinAttr(srv, "data-fin-donations-count") == "4"
|
||||
&& FinAttr(srv, "data-fin-donations-minor") == "7000",
|
||||
"a redelivered mutation is not counted twice");
|
||||
|
||||
// Default-deny: money no rule claims is WITHHELD from the page. It is
|
||||
// logged for classification, never published as a guess.
|
||||
Check(bunqPost("4824", "1234", "90.00", "NL99UNKNOWN00000000", "unlabelled transfer") == "200",
|
||||
"an unclassifiable mutation is still accepted (no redelivery loop)");
|
||||
Check(FinAttr(srv, "data-fin-donations-count") == "4"
|
||||
&& FinAttr(srv, "data-fin-expenses-minor") == "231200",
|
||||
"an unclassified mutation is withheld from every total");
|
||||
|
||||
// An outgoing bill matched by description becomes a positive expense.
|
||||
bunqPost("4825", "9911", "-12.00", "DE00HETZNER00000000", "HETZNER ONLINE GMBH");
|
||||
Check(FinAttr(srv, "data-fin-expenses-minor") == "232400",
|
||||
"an outgoing bill lands in its expense category");
|
||||
srv.BodyHas("/financials", "2026-08-15", "the as-of date advances with the mutations");
|
||||
|
||||
// The page still publishes nothing but aggregates: no counterparty, no
|
||||
// description, no id, no timestamp. This is the assertion that would
|
||||
// catch a well-meant future edit adding a "recent activity" list.
|
||||
for (std::string_view leak : { "NL55BUNQ2025123456", "Someone", "Thanks for imsd",
|
||||
"4823", "09:31" }) {
|
||||
srv.BodyLacks("/financials", std::string(leak),
|
||||
std::format("financials leak no transaction detail ({})", leak));
|
||||
}
|
||||
|
||||
// And nothing identifying was written to disk either — the ingest ledger
|
||||
// holds opaque ids and counters, and no other file learned the donor
|
||||
// exists.
|
||||
{
|
||||
bool persisted = false;
|
||||
for (const auto& entry :
|
||||
std::filesystem::recursive_directory_iterator(srv.Work())) {
|
||||
if (!entry.is_regular_file()) continue;
|
||||
if (ReadFile(entry.path()).find("NL55BUNQ2025123456") != std::string::npos) {
|
||||
persisted = true;
|
||||
std::println(std::cerr, " IBAN found in {}", entry.path().string());
|
||||
}
|
||||
}
|
||||
Check(!persisted, "no counterparty IBAN is persisted anywhere");
|
||||
}
|
||||
|
||||
return Finish();
|
||||
}
|
||||
245
tests/ShouldServePostPages/main.cpp
Normal file
245
tests/ShouldServePostPages/main.cpp
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
// Post media and post pages over real HTTP. The media IS the content of these
|
||||
// posts (screen recordings of the work), and it must come from our own
|
||||
// origin: the privacy notice states that everything the browser loads comes
|
||||
// from catcrafts.net, and a third-party embed would send every visitor's IP
|
||||
// to whichever instance hosted the file.
|
||||
|
||||
import std;
|
||||
import Catcrafts.E2eHarness;
|
||||
|
||||
using namespace Catcrafts::E2e;
|
||||
|
||||
namespace {
|
||||
|
||||
// Media loaded from a third party — `poster` is in the list because a video
|
||||
// poster is fetched on page load exactly like an <img> src is.
|
||||
const std::regex kThirdPartyMedia(
|
||||
R"((src|srcset|href|poster)="https?://[^"]*\.(mp4|webm|webp|avif|png|jpe?g|gif))");
|
||||
|
||||
std::vector<std::string> PostPathsFromSitemap(TestServer& srv, std::size_t limit) {
|
||||
const std::string sitemap = srv.Body("/sitemap.xml");
|
||||
const std::regex slug(R"(/posts/[a-z0-9-]+)");
|
||||
std::vector<std::string> out;
|
||||
for (auto it = std::sregex_iterator(sitemap.begin(), sitemap.end(), slug);
|
||||
it != std::sregex_iterator() && out.size() < limit; ++it) {
|
||||
if (std::ranges::find(out, it->str()) == out.end()) out.push_back(it->str());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
TestServer srv(argv[1], 8214);
|
||||
|
||||
const std::string posts = srv.Body("/posts");
|
||||
|
||||
// ── post media ────────────────────────────────────────────────────
|
||||
Check(std::regex_search(posts, std::regex(R"(<(img|video) class="post-media__item")")),
|
||||
"/posts embeds its media");
|
||||
Check(!std::regex_search(posts, kThirdPartyMedia),
|
||||
"/posts loads no media from a third party");
|
||||
// Dimensions prevent layout shift as each file arrives. Needs ffprobe at
|
||||
// fetch time — a build host without it produces no dimensions at all,
|
||||
// which is what this catches.
|
||||
Check(std::regex_search(posts, std::regex(
|
||||
R"(<img class="post-media__item"[^>]*width="[0-9]+" height="[0-9]+")")),
|
||||
"images carry width/height");
|
||||
// Videos too. This assertion exists because they silently lost theirs:
|
||||
// ffprobe appends an empty CSV field for some files, so parsing
|
||||
// `width,height` as one joined string yielded a height of "480x" and the
|
||||
// guard discarded both.
|
||||
Check(std::regex_search(posts, std::regex(
|
||||
R"(<video class="post-media__item"[^>]*width="[0-9]+" height="[0-9]+")")),
|
||||
"videos carry width/height");
|
||||
// A poster is the frame shown before anyone presses play, and these posts
|
||||
// ARE their video. "At least one" rather than "every one": an instance
|
||||
// that generated no thumbnail is a legitimate empty poster, but zero
|
||||
// posters across every video means the fetch/mirror/render chain broke.
|
||||
Check(std::regex_search(posts, std::regex(
|
||||
R"(<video class="post-media__item"[^>]*poster="/media/)")),
|
||||
"videos carry a locally-hosted poster");
|
||||
// A video offering an AV1 <source> must offer an H.264 one after it: the
|
||||
// codecs parameter is what lets a browser without AV1 skip to a file it
|
||||
// can play. Conditional — a build whose posts carry no AV1 has nothing to
|
||||
// check.
|
||||
if (posts.find("codecs=av01") != std::string::npos) {
|
||||
Check(std::regex_search(posts, std::regex(
|
||||
R"(<source src="/media/[^"]*\.h264\.mp4" type="video/mp4">)")),
|
||||
"AV1 videos carry an H.264 fallback source");
|
||||
}
|
||||
// preload="metadata", not auto: several 5 MB recordings must not all
|
||||
// download on page load.
|
||||
Check(posts.find("preload=\"metadata\"") != std::string::npos,
|
||||
"video does not preload its whole body");
|
||||
|
||||
// ── post pages ────────────────────────────────────────────────────
|
||||
// The post page is where the body lives, and the body is the reason the
|
||||
// site has anything for a search engine to index beyond a list of links
|
||||
// off it. Its slug is data, so take one from the page rather than
|
||||
// hardcoding a title that will be wrong the week after it is written.
|
||||
std::string postPath;
|
||||
{
|
||||
std::smatch m;
|
||||
if (std::regex_search(posts, m, std::regex(R"lit(href="(/posts/[a-z0-9-]+)")lit"))) {
|
||||
postPath = m[1].str();
|
||||
}
|
||||
}
|
||||
Check(!postPath.empty(), "/posts links a post page; a body exists somewhere");
|
||||
if (!postPath.empty()) {
|
||||
srv.CheckStatus(postPath, "200");
|
||||
srv.BodyHas("/posts", "Read the full post", "/posts offers the full post");
|
||||
// And it trails the excerpt, immediately after the ellipsis the
|
||||
// truncation left, rather than sitting as its own row below the
|
||||
// media. The excerpt is escaped text, so nothing but the link can put
|
||||
// a '<' between the two.
|
||||
Check(std::regex_search(posts, std::regex(
|
||||
R"(<p class="post-card__excerpt">[^<]*<a class="link-more" href="/posts/)")),
|
||||
"read-more trails the excerpt");
|
||||
|
||||
const std::string page = srv.Body(postPath);
|
||||
Check(page.find("<div class=\"post-body\">") != std::string::npos,
|
||||
"post page carries the rendered body");
|
||||
// Rendered, not dumped: a body that reached the page as literal
|
||||
// Markdown would show its own asterisks and hashes to the reader and
|
||||
// to a crawler.
|
||||
Check(std::regex_search(page, std::regex(R"(<(p|h2|h3|h4|ul|ol|blockquote|pre)>)")),
|
||||
"post body is real markup, not literal Markdown");
|
||||
// The canonical points here, not at the instance. That is the entire
|
||||
// SEO argument for hosting the body: two copies of the text exist,
|
||||
// and this says which one is the original as far as this site is
|
||||
// concerned.
|
||||
Check(page.find("rel=\"canonical\" href=\"https://catcrafts.net/posts/")
|
||||
!= std::string::npos,
|
||||
"post page is its own canonical");
|
||||
Check(page.find("\"@type\":\"BlogPosting\"") != std::string::npos,
|
||||
"post page carries BlogPosting JSON-LD");
|
||||
Check(page.find("\"@id\":\"https://catcrafts.net/#organization\"") != std::string::npos,
|
||||
"post JSON-LD joins the organization node");
|
||||
Check(page.find("\"@id\":\"https://catcrafts.net/about#person\"") != std::string::npos,
|
||||
"post JSON-LD joins the founder node");
|
||||
Check(page.find("property=\"og:type\" content=\"article\"") != std::string::npos,
|
||||
"post page is an article to og:");
|
||||
// Hosting the body does not mirror the discussion; the thread is
|
||||
// still one click away and is still where the comments are.
|
||||
Check(std::regex_search(page, std::regex(R"lit(href="https://[a-z0-9.-]+/post/[0-9]+")lit")),
|
||||
"post page still links its thread");
|
||||
// The body is prose, not an application.
|
||||
Check(page.find("<script>") == std::string::npos,
|
||||
"post page ships no executable script");
|
||||
Check(page.find("<base") == std::string::npos, "post page has no base tag");
|
||||
|
||||
// Every inline image and video a body embeds is mirrored, exactly
|
||||
// like a card's media — "everything comes from catcrafts.net" covers
|
||||
// href as well as src, so a body linking a .webp on someone else's
|
||||
// instance is the same leak as embedding one.
|
||||
const std::vector<std::string> pages = PostPathsFromSitemap(srv, 20);
|
||||
std::vector<std::string> referencedMedia;
|
||||
for (const std::string& pg : pages) {
|
||||
const std::string body = srv.Body(pg);
|
||||
Check(!std::regex_search(body, kThirdPartyMedia),
|
||||
std::format("{} loads no media from a third party", pg));
|
||||
const std::regex mediaRef(R"lit((src|srcset)="(/media/[^"]+)")lit");
|
||||
for (auto it = std::sregex_iterator(body.begin(), body.end(), mediaRef);
|
||||
it != std::sregex_iterator(); ++it) {
|
||||
const std::string f = (*it)[2].str();
|
||||
if (std::ranges::find(referencedMedia, f) == referencedMedia.end()) {
|
||||
referencedMedia.push_back(f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The image format ladder: AVIF first, the mirrored original next,
|
||||
// and a PNG on the <img> underneath, so exactly one file is fetched
|
||||
// and every browser can read one of them. Order is the whole point —
|
||||
// a browser takes the first source it understands.
|
||||
if (page.find("<picture>") != std::string::npos) {
|
||||
Check(std::regex_search(page, std::regex(
|
||||
R"(<picture><source srcset="/media/[^"]+\.avif" type="image/avif">)")),
|
||||
"inline images lead with an AVIF source");
|
||||
Check(std::regex_search(page, std::regex(
|
||||
R"lit(<img class="post-media__item"[^>]*src="/media/[^"]+\.png")lit")),
|
||||
"inline images fall back to a PNG the img itself points at");
|
||||
// Every tier has to be a file that exists, or the ladder serves a
|
||||
// 404 to whichever browsers pick that rung — precisely the set of
|
||||
// browsers nobody testing this site is using. The files are
|
||||
// served by Caddy rather than by this server, so they are checked
|
||||
// on disk. NOT hardcoded to ./media: CI points the mirror at the
|
||||
// persistent mount instead (E2E_MEDIA_DIR) so the copies survive
|
||||
// a deploy.
|
||||
std::filesystem::path mediaDir;
|
||||
if (const char* env = std::getenv("E2E_MEDIA_DIR"); env && *env) {
|
||||
mediaDir = env;
|
||||
} else {
|
||||
for (std::string_view d : { "media", "/deploy-app/media" }) {
|
||||
if (std::filesystem::is_directory(d)) { mediaDir = d; break; }
|
||||
}
|
||||
}
|
||||
if (mediaDir.empty()) {
|
||||
std::println("note: no media directory found; set E2E_MEDIA_DIR — "
|
||||
"the media-files-exist check did not run");
|
||||
} else {
|
||||
std::size_t missing = 0;
|
||||
for (const std::string& f : referencedMedia) {
|
||||
if (!std::filesystem::exists(mediaDir / f.substr(std::string_view("/media/").size()))) {
|
||||
++missing;
|
||||
// Bounded: a wrong directory makes EVERY file missing,
|
||||
// and a hundred identical lines buries the one fact
|
||||
// that matters.
|
||||
if (missing <= 5) std::println(std::cerr, " missing: {}", f);
|
||||
}
|
||||
}
|
||||
Check(missing == 0,
|
||||
std::format("every referenced media file is in {}", mediaDir.string()),
|
||||
std::format("{} referenced file(s) missing", missing));
|
||||
}
|
||||
} else {
|
||||
std::println("note: no <picture> on {} — ffmpeg absent at mirror time? "
|
||||
"the format-ladder checks did not run", postPath);
|
||||
}
|
||||
// Inline screenshots get dimensions from the sidecar list
|
||||
// fetch-media.sh writes, because Markdown syntax has nowhere to carry
|
||||
// them. Conditional: a post whose body embeds nothing has nothing to
|
||||
// check.
|
||||
if (page.find("<img class=\"post-media__item\"") != std::string::npos) {
|
||||
Check(std::regex_search(page, std::regex(
|
||||
R"(<img class="post-media__item"[^>]*width="[0-9]+" height="[0-9]+")")),
|
||||
"inline body images carry width/height");
|
||||
}
|
||||
}
|
||||
|
||||
// The sitemap has to advertise the pages, or hosting the bodies buys
|
||||
// nothing.
|
||||
Check(std::regex_search(srv.Body("/sitemap.xml"), std::regex(
|
||||
R"(<loc>https://catcrafts\.net/posts/[a-z0-9-]+</loc>)")),
|
||||
"sitemap lists the post pages");
|
||||
|
||||
// Every outbound thread link is a real permalink: absolute https, on some
|
||||
// instance, pointing at a numeric post id. A resolution failure
|
||||
// legitimately falls back to the author's copy, so this checks the shape
|
||||
// rather than naming a host.
|
||||
{
|
||||
const std::regex permalink(R"lit(href="https://[a-z0-9.-]+/post/[0-9]+")lit");
|
||||
std::size_t links = 0;
|
||||
for (auto it = std::sregex_iterator(posts.begin(), posts.end(), permalink);
|
||||
it != std::sregex_iterator(); ++it) {
|
||||
++links;
|
||||
}
|
||||
Check(links > 0, "posts list links its threads by permalink");
|
||||
// Nothing should link a post by a bare id or a relative path — that
|
||||
// would mean a permalink was rendered without its origin and silently
|
||||
// resolves to catcrafts.net.
|
||||
Check(!std::regex_search(posts, std::regex(R"lit(href="/post/[0-9]+")lit")),
|
||||
"no thread link resolves to catcrafts.net");
|
||||
}
|
||||
|
||||
return Finish();
|
||||
}
|
||||
173
tests/ShouldServePosts/main.cpp
Normal file
173
tests/ShouldServePosts/main.cpp
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
// Post pages: the routing, the loader's guard on what becomes a URL, and the
|
||||
// schema.org joins that keep every post attributed to the one Organization and
|
||||
// the one Person the rest of the site describes.
|
||||
|
||||
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() {
|
||||
// ── routing ───────────────────────────────────────────────────────
|
||||
Check(ParseRoute("/posts").kind == RouteKind::Posts, "route: /posts is the list");
|
||||
Check(ParseRoute("/posts/hello-world").kind == RouteKind::Post, "route: /posts/<slug>");
|
||||
Check(ParseRoute("/posts/hello-world").slug == "hello-world", "route: post slug captured");
|
||||
Check(ParseRoute("/posts/hello-world/").kind == RouteKind::Post,
|
||||
"route: trailing slash normalised");
|
||||
Check(ParseRoute("/posts/Hello").kind == RouteKind::NotFound,
|
||||
"route: uppercase slug is not a post URL");
|
||||
Check(ParseRoute("/posts/../etc").kind == RouteKind::NotFound,
|
||||
"route: traversal never reaches a lookup");
|
||||
Check(NavKindFor(RouteKind::Post) == RouteKind::Posts,
|
||||
"route: a post page highlights the Posts nav entry");
|
||||
Check(NavKindFor(RouteKind::LegacyBlog) == RouteKind::Posts,
|
||||
"route: the retired /blog URL highlights it too");
|
||||
Check(NavKindFor(RouteKind::Shop) == RouteKind::Shop, "route: a nav route is its own entry");
|
||||
|
||||
// ── the loader ────────────────────────────────────────────────────
|
||||
{
|
||||
const auto posts = LoadPosts(R"([
|
||||
{"title":"Good","slug":"good-post","permalink":"https://i.example/post/1",
|
||||
"body":"Hello.","published":"2026-01-01T00:00:00Z",
|
||||
"body_media":[{"src":"/media/a.webp","kind":"image","w":10,"h":20}]},
|
||||
{"title":"Bad slug","slug":"NOT A SLUG","permalink":"https://i.example/post/2",
|
||||
"body":"Hello."},
|
||||
{"title":"No body","slug":"no-body","permalink":"https://i.example/post/3"}
|
||||
])");
|
||||
Check(posts.size() == 3, "posts: all three load");
|
||||
if (posts.size() == 3) {
|
||||
Check(posts[0].HasPage() && posts[0].slug == "good-post", "posts: valid slug kept");
|
||||
Check(posts[0].body == "Hello.", "posts: body loaded");
|
||||
Check(posts[0].bodyMedia.size() == 1 && posts[0].bodyMedia[0].width == 10,
|
||||
"posts: body media loaded with dimensions");
|
||||
// A slug that could never match a route would render a "read more"
|
||||
// link to a 404 this site points at itself.
|
||||
Check(posts[1].slug.empty() && !posts[1].HasPage(),
|
||||
"posts: malformed slug is dropped, costing the page");
|
||||
// A title and a link out is not a page worth minting a URL for.
|
||||
Check(!posts[2].HasPage(), "posts: no body means no page");
|
||||
}
|
||||
|
||||
Views::SiteContent content;
|
||||
content.posts = posts;
|
||||
Check(content.FindPost("good-post") != nullptr, "posts: found by slug");
|
||||
Check(content.FindPost("no-body") == nullptr, "posts: a pageless post is not findable");
|
||||
Check(content.FindPost("nope") == nullptr, "posts: unknown slug is not found");
|
||||
// Which means the route 404s rather than rendering an empty article.
|
||||
Check(Views::RenderRoute(ParseRoute("/posts/no-body"), content).status == 404,
|
||||
"posts: a pageless slug is a real 404");
|
||||
Check(Views::RenderRoute(ParseRoute("/posts/good-post"), content).status == 200,
|
||||
"posts: a real post renders");
|
||||
}
|
||||
|
||||
// ── the page ──────────────────────────────────────────────────────
|
||||
{
|
||||
Post p;
|
||||
p.title = "Working GPS!";
|
||||
p.slug = "working-gps";
|
||||
p.permalink = "https://lemmy.example/post/42";
|
||||
p.community = "linuxphones@lemmy.example";
|
||||
p.published = "2026-06-26T23:16:25Z";
|
||||
p.excerpt = "A short summary.";
|
||||
p.body = "# How\n\nIt works.";
|
||||
PostMedia m;
|
||||
m.src = "/media/shot.webp";
|
||||
m.kind = "image";
|
||||
p.media.push_back(m);
|
||||
|
||||
const auto page = Views::RenderPost(p);
|
||||
Check(page.meta.canonical == "/posts/working-gps",
|
||||
"post page: canonical is this site, not the instance");
|
||||
Check(page.meta.ogType == "article", "post page: og:type is article");
|
||||
Check(page.meta.ogImage == "/media/shot.webp", "post page: og:image from the post media");
|
||||
Check(page.meta.description == p.excerpt, "post page: description is the excerpt");
|
||||
Check(page.main.View().find("<h2>How</h2>") != std::string_view::npos,
|
||||
"post page: the body is rendered, not escaped away");
|
||||
// The whole reason the body is hosted: the thread is still one click
|
||||
// away, and the reader is told where the discussion is.
|
||||
Check(page.main.View().find(p.permalink) != std::string_view::npos,
|
||||
"post page: still links the thread");
|
||||
|
||||
// The identity graph, same joins every other page makes. A typo'd @id
|
||||
// still renders and still validates — it just quietly splits this post
|
||||
// away from the entity the rest of the site describes.
|
||||
auto ld = Json::Parse(page.meta.jsonLd);
|
||||
Check(ld && ld->IsObject() && ld->Str("@type") == "BlogPosting",
|
||||
"post schema: parses as a BlogPosting");
|
||||
if (ld && ld->IsObject()) {
|
||||
Check(ld->Str("url") == "https://catcrafts.net/posts/working-gps",
|
||||
"post schema: url is the on-site page");
|
||||
Check(ld->Str("discussionUrl") == p.permalink,
|
||||
"post schema: the thread is the discussion, not the content");
|
||||
Check(ld->Str("datePublished") == p.published, "post schema: publication date");
|
||||
const Json::Value* author = ld->Find("author");
|
||||
const Json::Value* publisher = ld->Find("publisher");
|
||||
Check(author && author->Str("@id") == "https://catcrafts.net/about#person",
|
||||
"post schema: authored by the Person node on /about");
|
||||
Check(publisher && publisher->Str("@id") == "https://catcrafts.net/#organization",
|
||||
"post schema: published by the Organization node");
|
||||
}
|
||||
|
||||
// A post with a page is advertised as one from the list and from home;
|
||||
// one without keeps pointing at the thread, because there is nothing
|
||||
// here to send the reader to.
|
||||
const std::array<Post, 1> one{ p };
|
||||
const auto list = Views::RenderPosts(one);
|
||||
Check(list.main.View().find(R"(href="/posts/working-gps")") != std::string_view::npos,
|
||||
"posts list: links the on-site page");
|
||||
Check(list.main.View().find("Read the full post") != std::string_view::npos,
|
||||
"posts list: offers the full post");
|
||||
// Inside the excerpt paragraph, trailing the text — not a row of its
|
||||
// own below the media, where it was the same offer made a screen
|
||||
// further down.
|
||||
Check(list.main.View().find(
|
||||
R"(A short summary. <a class="link-more" href="/posts/working-gps">)"
|
||||
R"(Read the full post</a></p>)") != std::string_view::npos,
|
||||
"posts list: read-more trails the excerpt", list.main.View());
|
||||
|
||||
// With no excerpt there is no sentence to continue, so it falls back to
|
||||
// a row rather than vanishing with the paragraph that would have held it.
|
||||
Post unexcerpted = p;
|
||||
unexcerpted.excerpt.clear();
|
||||
const std::array<Post, 1> bare{ unexcerpted };
|
||||
const auto listBare = Views::RenderPosts(bare);
|
||||
Check(listBare.main.View().find("Read the full post") != std::string_view::npos,
|
||||
"posts list: an excerptless post still offers the full post");
|
||||
|
||||
Post bodyless = p;
|
||||
bodyless.body.clear();
|
||||
const std::array<Post, 1> none{ bodyless };
|
||||
const auto listNone = Views::RenderPosts(none);
|
||||
Check(listNone.main.View().find("Read the full post") == std::string_view::npos,
|
||||
"posts list: no read-more without a page to read");
|
||||
Check(listNone.main.View().find(p.permalink) != std::string_view::npos,
|
||||
"posts list: a pageless post still links its thread");
|
||||
}
|
||||
|
||||
if (failures != 0) {
|
||||
std::println(std::cerr, "{} check(s) failed", failures);
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
102
tests/ShouldServeRoutes/main.cpp
Normal file
102
tests/ShouldServeRoutes/main.cpp
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
// Status codes, redirects, response headers, and the sitemap/feed documents —
|
||||
// the things only a real request can show. A real 404 is the whole reason the
|
||||
// backend exists: a client-side router cannot produce one.
|
||||
|
||||
import std;
|
||||
import Catcrafts.E2eHarness;
|
||||
|
||||
using namespace Catcrafts::E2e;
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
TestServer srv(argv[1], 8210);
|
||||
|
||||
// ── status codes ──────────────────────────────────────────────────
|
||||
for (std::string_view p : { "/", "/about", "/shop", "/shop/fp6-pmos", "/projects",
|
||||
"/posts", "/demos", "/demos/raytracer", "/financials",
|
||||
"/legal/privacy", "/legal/terms", "/legal/imprint",
|
||||
"/feed.xml", "/sitemap.xml", "/api/healthz" }) {
|
||||
srv.CheckStatus(std::string(p), "200");
|
||||
}
|
||||
// Trailing slashes must normalise, not 404 or duplicate the canonical URL.
|
||||
srv.CheckStatus("/projects/", "200");
|
||||
srv.CheckStatus("/shop/", "200");
|
||||
// A real 404, which a client-side router cannot produce — this is the
|
||||
// whole reason the backend exists.
|
||||
srv.CheckStatus("/nope", "404");
|
||||
srv.CheckStatus("/shop/nope", "404");
|
||||
srv.CheckStatus("/legal/nope", "404");
|
||||
// A slug that cannot be one of ours is rejected before any lookup.
|
||||
srv.CheckStatus("/shop/BAD--slug", "404");
|
||||
srv.CheckStatus("/demos/nope", "404");
|
||||
// A post slug that parsed but names nothing must be a real 404, or every
|
||||
// typo and every retired post becomes an indexable empty page.
|
||||
srv.CheckStatus("/posts/nope", "404");
|
||||
srv.CheckStatus("/posts/BAD--slug", "404");
|
||||
// The retired blog URLs are still in the wild; they must redirect, not 404.
|
||||
srv.CheckStatus("/blog", "301");
|
||||
srv.CheckStatus("/blog/hello-world", "301");
|
||||
// /demo was the single-demo URL before there was a list; it must redirect,
|
||||
// not 404, because it was linked from the home page.
|
||||
srv.CheckStatus("/demo", "301");
|
||||
|
||||
// ── redirects ─────────────────────────────────────────────────────
|
||||
{
|
||||
const auto blog = srv.Get("/blog/hello-world");
|
||||
const auto it = blog.headers.find("location");
|
||||
Check(it != blog.headers.end() && it->second.starts_with("/posts"),
|
||||
"/blog/* sends Location: /posts",
|
||||
it == blog.headers.end() ? "no location header" : it->second);
|
||||
}
|
||||
{
|
||||
const auto demo = srv.Get("/demo");
|
||||
const auto it = demo.headers.find("location");
|
||||
Check(it != demo.headers.end() && it->second.starts_with("/demos"),
|
||||
"/demo sends Location: /demos",
|
||||
it == demo.headers.end() ? "no location header" : it->second);
|
||||
}
|
||||
|
||||
// ── headers ───────────────────────────────────────────────────────
|
||||
srv.HeaderHas("/", "x-content-type-options", "nosniff", "nosniff on pages");
|
||||
srv.HeaderHas("/", "cache-control", "public", "pages are cacheable");
|
||||
srv.HeaderHas("/nope", "x-robots-tag", "noindex", "404 is noindex");
|
||||
srv.HeaderHas("/feed.xml", "content-type", "application/atom", "feed content-type");
|
||||
srv.HeaderHas("/sitemap.xml", "content-type", "application/xml", "sitemap content-type");
|
||||
|
||||
// ── sitemap and feed content ──────────────────────────────────────
|
||||
srv.BodyHas("/sitemap.xml", "/shop/fp6-pmos", "sitemap lists the product");
|
||||
srv.BodyHas("/sitemap.xml", "/about", "sitemap lists the about page");
|
||||
srv.BodyHas("/sitemap.xml", "/legal/privacy", "sitemap lists the privacy page");
|
||||
srv.BodyHas("/sitemap.xml", "/demos", "sitemap lists the demos page");
|
||||
srv.BodyHas("/sitemap.xml", "/financials", "sitemap lists the financials page");
|
||||
srv.BodyLacks("/sitemap.xml", "/blog", "sitemap does not advertise the redirect");
|
||||
srv.BodyLacks("/sitemap.xml", "/order", "sitemap does not advertise order pages");
|
||||
srv.BodyHas("/feed.xml", "<feed xmlns=\"http://www.w3.org/2005/Atom\">", "feed is Atom");
|
||||
|
||||
// ── abuse ─────────────────────────────────────────────────────────
|
||||
srv.CheckStatus("/shop/fp6-pmos", "413", "POST",
|
||||
"email=a%40b.example&name=" + std::string(20000, 'x')
|
||||
+ "&street=x&postal=1&city=y&country=NL");
|
||||
{
|
||||
const auto json = srv.Post("/shop/fp6-pmos", "{}", "application/json");
|
||||
Check(json.status == "415", "POST with a JSON content-type -> 415", json.status);
|
||||
}
|
||||
srv.CheckStatus("/shop/nope", "404", "POST",
|
||||
"email=a%40b.example&name=Ada&street=x&postal=1&city=y&country=NL");
|
||||
srv.CheckStatus("/projects", "405", "POST", "x=1");
|
||||
// HEAD must not be a 500 or a body — some crawlers use it exclusively.
|
||||
srv.CheckStatus("/", "200", "HEAD");
|
||||
{
|
||||
const auto head = srv.Head("/");
|
||||
Check(head.body.empty(), "HEAD carries no body");
|
||||
}
|
||||
|
||||
return Finish();
|
||||
}
|
||||
219
tests/ShouldShipContent/main.cpp
Normal file
219
tests/ShouldShipContent/main.cpp
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
/*
|
||||
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 compiled-in catalogue and the schema.org identity graph. Content is
|
||||
// code now; these assertions are the contract the shop pages rely on,
|
||||
// checked against the actual shipped data.
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// ── the compiled-in catalogue ─────────────────────────────────────────
|
||||
void CatalogueContract() {
|
||||
using namespace Catcrafts::Money;
|
||||
|
||||
const auto& products = Content::Products();
|
||||
Check(products.size() == 1, "content: one product");
|
||||
if (products.size() == 1) {
|
||||
const Product& pr = products[0];
|
||||
Check(pr.slug == "fp6-pmos", "content: product slug");
|
||||
// Coming-soon is the pre-launch state; launch flips it to
|
||||
// "available" and this check keeps passing either way.
|
||||
Check(pr.Buyable() || pr.ComingSoon(),
|
||||
"content: product is buyable or deliberately coming soon");
|
||||
Check(pr.variants.size() == 3, "content: three colours");
|
||||
// Cost-plus pricing, derived in code: supplier + €50, exactly.
|
||||
Check(pr.FindVariant("green") && pr.FindVariant("green")->priceInclMinor == 56330,
|
||||
"content: green = 513.30 supplier + 50 markup");
|
||||
Check(pr.FindVariant("black") && pr.FindVariant("black")->priceInclMinor == 56930,
|
||||
"content: black = 519.30 supplier + 50 markup");
|
||||
Check(pr.FindVariant("white") && pr.FindVariant("white")->priceInclMinor == 65488,
|
||||
"content: white = 604.88 supplier + 50 markup");
|
||||
Check(pr.FindVariant("mauve") == nullptr, "content: unknown colour is null");
|
||||
Check(pr.priceInclMinor == 56330, "content: from-price is the cheapest variant");
|
||||
Check(pr.CheapestVariant() && pr.CheapestVariant()->slug == "green",
|
||||
"content: cheapest is green");
|
||||
Check(pr.safetyNote.find("112") != std::string::npos
|
||||
&& pr.safetyNote.find("not yet verified") != std::string::npos,
|
||||
"content: emergency-calling safety warning present and honest");
|
||||
Check(pr.warranty.find("TODO") == std::string::npos && pr.warranty.size() > 100,
|
||||
"content: warranty is written, not a placeholder");
|
||||
// The product page's schema.org record must parse with our own
|
||||
// JSON parser and carry one variant Product per colour, each
|
||||
// with its ONE offer — built from the same integers the
|
||||
// checkout charges.
|
||||
{
|
||||
// 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()) {
|
||||
if (const Json::Value* v = ld->Find("hasVariant");
|
||||
v && v->IsArray() && v->array.size() == pr.variants.size()) {
|
||||
variantsOk = true;
|
||||
for (const Json::Value& node : v->array) {
|
||||
const Json::Value* o = node.Find("offers");
|
||||
variantsOk = variantsOk && node.Str("@type") == "Product"
|
||||
&& o && o->IsObject();
|
||||
}
|
||||
}
|
||||
}
|
||||
Check(ld && ld->IsObject() && ld->Str("@type") == "ProductGroup" && variantsOk,
|
||||
"schema: product JSON-LD parses, one variant per colour");
|
||||
// Merchant-grade fields: shipping, returns, sku, group id —
|
||||
// what Merchant Center's website-crawl feed reads at launch
|
||||
// (productGroupID is its item_group_id).
|
||||
Check(pp.meta.jsonLd.find("OfferShippingDetails") != std::string::npos
|
||||
&& pp.meta.jsonLd.find("MerchantReturnPolicy") != std::string::npos
|
||||
&& 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");
|
||||
Check(Content::LegalPages().size() == 3, "content: three legal pages");
|
||||
Check(Content::AboutPage().sections.size() >= 3
|
||||
&& !Content::AboutPage().sections[0].body.empty()
|
||||
&& Content::AboutPage().sections[0].body[0].find("Jorijn van der Graaf")
|
||||
!= std::string::npos,
|
||||
"content: about page names the founder");
|
||||
Check(!Content::Demos().empty(), "content: demos present");
|
||||
}
|
||||
|
||||
// ── the identity graph ────────────────────────────────────────────────
|
||||
// "Catcrafts" is two common words with no space, so it competes with a
|
||||
// decade of kids' craft blogs, Etsy and a Minecraft server on the
|
||||
// singular domain. The way out is not prose: it is one registered entity
|
||||
// that every page points at by @id. Those joins are worth asserting
|
||||
// because breaking one is silent — a typo'd @id still renders, still
|
||||
// validates as JSON-LD, and still splits the graph back into three
|
||||
// same-named strangers, which is the exact failure this markup exists to
|
||||
// prevent.
|
||||
void IdentityGraph() {
|
||||
constexpr std::string_view kOrgId = "https://catcrafts.net/#organization";
|
||||
|
||||
auto home = Views::RenderHome(Content::Projects(), std::span<const Post>{});
|
||||
auto ld = Json::Parse(home.meta.jsonLd);
|
||||
const Json::Value* org = nullptr;
|
||||
const Json::Value* site = nullptr;
|
||||
if (ld && ld->IsObject()) {
|
||||
if (const Json::Value* g = ld->Find("@graph"); g && g->IsArray()) {
|
||||
for (const Json::Value& node : g->array) {
|
||||
if (node.Str("@type") == "Organization") org = &node;
|
||||
if (node.Str("@type") == "WebSite") site = &node;
|
||||
}
|
||||
}
|
||||
}
|
||||
Check(org && site, "schema: home graph parses, carries Organization and WebSite");
|
||||
|
||||
if (org && site) {
|
||||
Check(org->Str("@id") == kOrgId, "schema: organization node is identified");
|
||||
Check(site->Str("@id") == "https://catcrafts.net/#website",
|
||||
"schema: website node is identified");
|
||||
// The join that makes two nodes one entity rather than two.
|
||||
const Json::Value* publisher = site->Find("publisher");
|
||||
Check(publisher && publisher->IsObject() && publisher->Str("@id") == kOrgId,
|
||||
"schema: website is published by the organization node");
|
||||
// The navigational query "catcrafts" is answered from the site
|
||||
// entity, so the spellings people type belong on it.
|
||||
const Json::Value* alt = site->Find("alternateName");
|
||||
Check(alt && alt->IsArray() && !alt->array.empty(),
|
||||
"schema: website carries the spellings people type");
|
||||
|
||||
// Registry numbers, typed. These are the part no name-twin can
|
||||
// produce — each one is checkable against a public register,
|
||||
// which also means a wrong value is worse than no value.
|
||||
bool kvk = false, vat = false, eori = false;
|
||||
if (const Json::Value* ids = org->Find("identifier"); ids && ids->IsArray()) {
|
||||
for (const Json::Value& id : ids->array) {
|
||||
if (id.Str("propertyID") == "KVK") kvk = id.Str("value") == "78437059";
|
||||
if (id.Str("propertyID") == "VAT") vat = id.Str("value") == "NL003329281B38";
|
||||
if (id.Str("propertyID") == "EORI") eori = id.Str("value") == "NL1900095326";
|
||||
}
|
||||
}
|
||||
Check(kvk && vat && eori, "schema: KVK, VAT and EORI present and exact");
|
||||
|
||||
// The name-twin guard. "Cat Crafts" with a space is the generic
|
||||
// craft phrase owned by everyone else; claiming it as an alternate
|
||||
// name argues for merging this entity into the corpus it needs to
|
||||
// stay distinct from.
|
||||
Check(home.meta.jsonLd.find("Cat Crafts") == std::string::npos,
|
||||
"schema: the spaced generic is not claimed as a brand name");
|
||||
}
|
||||
|
||||
// Cross-page joins: both must name the SAME @id the home page defines.
|
||||
auto about = Views::RenderAbout(Content::AboutPage());
|
||||
Check(about.meta.jsonLd.find(kOrgId) != std::string::npos,
|
||||
"schema: about joins the founder to the organization node");
|
||||
if (!Content::Products().empty()) {
|
||||
auto pp = Views::RenderProduct(Content::Products()[0], Rates{});
|
||||
Check(pp.meta.jsonLd.find(kOrgId) != std::string::npos,
|
||||
"schema: offers are sold by the organization node");
|
||||
}
|
||||
|
||||
// The person join, same mechanism in the other direction: home's
|
||||
// founder and about's mainEntity must name one Person node, or "who
|
||||
// founded Catcrafts" splits into two same-named strangers too.
|
||||
constexpr std::string_view kPersonId = "https://catcrafts.net/about#person";
|
||||
Check(home.meta.jsonLd.find(kPersonId) != std::string::npos
|
||||
&& about.meta.jsonLd.find(kPersonId) != std::string::npos,
|
||||
"schema: founder and about name one Person node");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
CatalogueContract();
|
||||
IdentityGraph();
|
||||
|
||||
if (failures != 0) {
|
||||
std::println(std::cerr, "{} check(s) failed", failures);
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
151
tests/ShouldStayScriptFree/main.cpp
Normal file
151
tests/ShouldStayScriptFree/main.cpp
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
/*
|
||||
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 no-JavaScript guarantee. The site must be complete without the wasm
|
||||
// module — if these fail, the SSR work has regressed and crawlers see an
|
||||
// empty page again. Shop pages carry exactly ONE pinned-down inline script
|
||||
// (the timezone price hint); everything else ships none at all.
|
||||
|
||||
import std;
|
||||
import Catcrafts.E2eHarness;
|
||||
|
||||
using namespace Catcrafts::E2e;
|
||||
|
||||
namespace {
|
||||
|
||||
// Visible prose only: strip tags, so an instance name inside an href (a
|
||||
// post's own permalink necessarily contains one) does not trip the check.
|
||||
std::string StripTags(std::string_view html) {
|
||||
std::string out;
|
||||
out.reserve(html.size());
|
||||
bool inTag = false;
|
||||
for (char c : html) {
|
||||
if (c == '<') inTag = true;
|
||||
else if (c == '>') { inTag = false; out.push_back(' '); }
|
||||
else if (!inTag) out.push_back(c);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string Lower(std::string s) {
|
||||
for (char& c : s) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
|
||||
return s;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
TestServer srv(argv[1], 8211);
|
||||
|
||||
// ── the no-JavaScript guarantee ───────────────────────────────────
|
||||
srv.BodyHas("/projects", "imsd", "/projects has content in the HTML");
|
||||
srv.BodyHas("/projects", "<title>Projects", "/projects has a real title");
|
||||
srv.BodyLacks("/projects", "<script", "/projects ships no script at all");
|
||||
srv.BodyLacks("/legal/privacy", "<script", "/legal/privacy ships no script");
|
||||
srv.BodyHas("/financials", "<title>Financials", "/financials has a real title");
|
||||
srv.BodyLacks("/financials", "<script", "/financials ships no script");
|
||||
|
||||
// Placeholders are dev-only markers; one reaching production is a content
|
||||
// bug (an imprint that says PLACEHOLDER once shipped exactly that way).
|
||||
for (std::string_view pg : { "/legal/privacy", "/legal/terms", "/legal/imprint",
|
||||
"/shop/fp6-pmos", "/financials" }) {
|
||||
srv.BodyLacks(std::string(pg), "PLACEHOLDER",
|
||||
std::format("{} ships no placeholder markers", pg));
|
||||
}
|
||||
|
||||
// Shop pages are the one exception to script-free: they carry exactly ONE
|
||||
// EXECUTABLE inline script — the timezone price hint, whose tag is the
|
||||
// bare <script>. JSON-LD blocks (<script type="application/ld+json">) are
|
||||
// inert data the browser never executes, so they don't count against the
|
||||
// rule. Pin the shape hard: inline only (no src=, so nothing external can
|
||||
// ever ride in under this exception), no network APIs, and the page must
|
||||
// remain complete without it — both prices in the markup regardless.
|
||||
for (std::string_view pg : { "/shop", "/shop/fp6-pmos" }) {
|
||||
const std::string body = srv.Body(std::string(pg));
|
||||
const std::size_t n = CountOccurrences(body, "<script>");
|
||||
Check(n == 1,
|
||||
std::format("{} carries exactly one executable script (the price hint)", pg),
|
||||
std::format("expected 1 bare <script>, got {}", n));
|
||||
const std::regex srcTag(R"(<script[^>]*src=)");
|
||||
Check(!std::regex_search(body, srcTag),
|
||||
std::format("{} script is inline, not external", pg));
|
||||
const std::size_t open = body.find("<script>");
|
||||
const std::size_t close = body.find("</script>", open);
|
||||
std::string_view inlineScript;
|
||||
if (open != std::string::npos && close != std::string::npos) {
|
||||
inlineScript = std::string_view(body).substr(open, close - open);
|
||||
}
|
||||
bool network = false;
|
||||
for (std::string_view api : { "fetch", "XMLHttpRequest", "WebSocket",
|
||||
"navigator.sendBeacon" }) {
|
||||
network = network || inlineScript.find(api) != std::string_view::npos;
|
||||
}
|
||||
Check(!network, std::format("{} script makes no network calls", pg));
|
||||
}
|
||||
srv.BodyHas("/shop/fp6-pmos", "cc-noneu", "price hint tags the non-EU outcome");
|
||||
srv.BodyHas("/shop/fp6-pmos", "cc-eu", "price hint tags the confirmed-EU outcome too");
|
||||
|
||||
// The renderer loads only where a demo entry declares needsWasm — the
|
||||
// demo LIST is a content page and must stay free of it.
|
||||
srv.BodyLacks("/demos", "<script", "/demos itself ships no script");
|
||||
srv.BodyHas("/demos/raytracer", "id=\"webgpu-demo\"", "raytracer page has the mount element");
|
||||
// Exactly one chrome root: the wasm adopts the server's, never builds a second.
|
||||
{
|
||||
const std::size_t roots =
|
||||
CountOccurrences(srv.Body("/demos/raytracer"), "id=\"catcrafts-root\"");
|
||||
Check(roots == 1, "raytracer page has exactly one chrome root",
|
||||
std::format("expected 1, got {}", roots));
|
||||
}
|
||||
|
||||
// ── SSR / wasm head interaction ───────────────────────────────────
|
||||
// catcrafts-head.js used to set document.title unconditionally, which
|
||||
// replaced the server's per-route title with the generic site name and
|
||||
// appended a second stylesheet, favicon and viewport tag. The
|
||||
// <meta name="cc-ssr"> marker is what it now checks; if that marker stops
|
||||
// being emitted the guard silently stops working, so assert it is present
|
||||
// and that the head is not duplicated.
|
||||
srv.BodyHas("/demos/raytracer", "name=\"cc-ssr\"", "SSR marker present for head.js to detect");
|
||||
srv.BodyHas("/demos/raytracer", "<title>Real-time ray tracer",
|
||||
"demo page keeps its route-specific title");
|
||||
{
|
||||
const std::string body = srv.Body("/demos/raytracer");
|
||||
for (std::string_view probe : { "rel=\"stylesheet\"", "rel=\"icon\"",
|
||||
"name=\"viewport\"" }) {
|
||||
const std::size_t n = CountOccurrences(body, probe);
|
||||
Check(n == 1, std::format("demo page has exactly one {}", probe),
|
||||
std::format("expected 1, got {}", n));
|
||||
}
|
||||
}
|
||||
|
||||
// The base tag belongs only where the runtime needs it. On a content page
|
||||
// it is dead weight and one more thing that could retarget a future
|
||||
// relative link.
|
||||
srv.BodyLacks("/posts", "<base", "/posts has no base tag");
|
||||
srv.BodyLacks("/shop/fp6-pmos", "<base", "/shop/<slug> has no base tag");
|
||||
|
||||
// ── home page actions ─────────────────────────────────────────────
|
||||
srv.BodyHas("/", "Browse projects", "home links to projects");
|
||||
srv.BodyHas("/", "Browse shop", "home links to the shop");
|
||||
srv.BodyLacks("/", "ray tracer", "home no longer pushes the ray tracer");
|
||||
|
||||
// ── instance-agnostic copy ────────────────────────────────────────
|
||||
// The account lives on one instance but posts go into communities on
|
||||
// others, so no page should name a specific instance as though it were
|
||||
// the home of the discussion. In visible text, not in href values.
|
||||
for (std::string_view pg : { "/", "/posts", "/shop" }) {
|
||||
const std::string prose = Lower(StripTags(srv.Body(std::string(pg))));
|
||||
Check(prose.find("ani.social") == std::string::npos,
|
||||
std::format("{} names no specific instance in visible text", pg));
|
||||
}
|
||||
srv.BodyHas("/posts", "fediverse", "/posts refers to the fediverse generally");
|
||||
// The fediverse account is not advertised at all — only individual posts are.
|
||||
srv.BodyLacks("/", "/u/", "footer does not link a fediverse profile");
|
||||
srv.BodyLacks("/posts", "/u/", "/posts links no account profile, only threads");
|
||||
|
||||
return Finish();
|
||||
}
|
||||
236
tests/ShouldValidateForms/main.cpp
Normal file
236
tests/ShouldValidateForms/main.cpp
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
/*
|
||||
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;
|
||||
}
|
||||
338
tests/harness/Catcrafts.E2eHarness.cppm
Normal file
338
tests/harness/Catcrafts.E2eHarness.cppm
Normal file
|
|
@ -0,0 +1,338 @@
|
|||
/*
|
||||
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 black-box test harness: spawns the REAL catcrafts-server binary on a
|
||||
// scratch port with a temporary orders file and the FAKE payment rails, so a
|
||||
// suite never touches real data, never dials Mollie, and needs no setup.
|
||||
//
|
||||
// This is the C++ port of what tools/e2e.sh used to set up in shell. Each
|
||||
// suite is its own process (crafter-build runs them in PARALLEL), so every
|
||||
// suite must use a UNIQUE port — see the AddTest declarations in project.cpp.
|
||||
//
|
||||
// Compiled into each suite via the AddTest(name, interfaces) overload rather
|
||||
// than linked as a library: the harness is test scaffolding, not product
|
||||
// code, and this keeps it out of every shipped artifact.
|
||||
|
||||
module;
|
||||
#include <fcntl.h>
|
||||
#include <signal.h>
|
||||
#include <stdlib.h>
|
||||
#include <sys/wait.h>
|
||||
#include <unistd.h>
|
||||
export module Catcrafts.E2eHarness;
|
||||
import std;
|
||||
import Crafter.Network;
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
export namespace Catcrafts::E2e {
|
||||
|
||||
inline int failures = 0;
|
||||
|
||||
inline 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);
|
||||
}
|
||||
|
||||
inline int Finish() {
|
||||
if (failures != 0) {
|
||||
std::println(std::cerr, "{} check(s) failed", failures);
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
inline void WriteFile(const fs::path& p, std::string_view content) {
|
||||
std::ofstream(p, std::ios::binary) << content;
|
||||
}
|
||||
|
||||
inline std::string ReadFile(const fs::path& p) {
|
||||
std::ifstream in(p, std::ios::binary);
|
||||
if (!in) return {};
|
||||
std::ostringstream buf;
|
||||
buf << in.rdbuf();
|
||||
return buf.str();
|
||||
}
|
||||
|
||||
inline std::size_t CountOccurrences(std::string_view haystack, std::string_view needle) {
|
||||
if (needle.empty()) return 0;
|
||||
std::size_t n = 0;
|
||||
for (std::size_t pos = haystack.find(needle); pos != std::string_view::npos;
|
||||
pos = haystack.find(needle, pos + needle.size())) {
|
||||
++n;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
// The shipping rate table. Shipping has no compiled-in fallback — the carrier
|
||||
// table is the only source of prices — so without this file every checkout
|
||||
// correctly refuses and the whole order suite would be testing the refusal
|
||||
// path by accident.
|
||||
//
|
||||
// This is byte-for-byte the cache the daily Sendcloud refresh writes, so the
|
||||
// suites drive the production lookup with no test-only hook that could drift
|
||||
// from it: country -> [[maxWeightGrams, consumerCents], ...], prices already
|
||||
// VAT-inclusive (the gross-up happens at fetch, not at load).
|
||||
//
|
||||
// The single-unit rates are the €15 / €25 / €55 the totals assert. The second
|
||||
// band exists so the too-heavy refusal has a real ceiling to hit:
|
||||
// 10 kg / 700 g per unit = 14 units per parcel.
|
||||
inline constexpr std::string_view kShippingFixture =
|
||||
R"({"method":"e2e fixture","fetched_at":"2026-01-01T00:00:00Z","per_country":{)"
|
||||
"\n"
|
||||
R"("NL":[[2000,1500],[10000,2900]],)"
|
||||
"\n"
|
||||
R"("DE":[[2000,2500],[10000,4200]],)"
|
||||
"\n"
|
||||
R"("GB":[[2000,5500],[10000,7900]]}})"
|
||||
"\n";
|
||||
|
||||
struct ServerOptions {
|
||||
// BOTH slots on the fake rail, so the suites cover the payment CHOICE as
|
||||
// well as the lifecycle. Which rail is behind each slot is exactly the
|
||||
// part these tests should not care about. Both fakes share one marker
|
||||
// file, so touching it settles whichever orders are outstanding.
|
||||
std::vector<std::string> extraArgs = { "--rail=fake", "--crypto-rail=fake-crypto" };
|
||||
// An ephemeral GPG key so invoice signing runs the REAL signing path and
|
||||
// the suite can verify the signature (checkout suite only — it costs a
|
||||
// keygen).
|
||||
bool gpg = false;
|
||||
// A fake sendmail, so the mailer's REAL path — build the MIME message,
|
||||
// attach the signed invoice, shell out — runs with zero network. Each
|
||||
// accepted message lands as its own mail-<n>.eml.
|
||||
bool mailer = false;
|
||||
// Extra environment for the server (e.g. BUNQ_CALLBACK_SECRET).
|
||||
std::vector<std::pair<std::string, std::string>> env;
|
||||
};
|
||||
|
||||
class TestServer {
|
||||
public:
|
||||
TestServer(std::string binary, std::uint16_t port, ServerOptions options = {})
|
||||
: port_(port) {
|
||||
work_ = fs::temp_directory_path()
|
||||
/ std::format("catcrafts-e2e-{}-{}", port, ::getpid());
|
||||
std::error_code ec;
|
||||
fs::remove_all(work_, ec);
|
||||
fs::create_directories(work_);
|
||||
orders_ = work_ / "orders.jsonl";
|
||||
|
||||
// Deterministic environment: a developer shell that sourced the repo
|
||||
// .env must not leak real provider keys into the test server — live
|
||||
// Sendcloud rates would silently change the shipping totals the
|
||||
// suites assert.
|
||||
for (const char* v : { "MOLLIE_API_KEY", "EURC_CHAINS", "EURC_POOL",
|
||||
"SENDCLOUD_PUBLIC_KEY", "SENDCLOUD_SECRET_KEY",
|
||||
"SENDCLOUD_METHOD", "BUNQ_CALLBACK_SECRET",
|
||||
"INVOICE_GPG_KEY", "MAIL_COMMAND", "MAIL_FROM" }) {
|
||||
::unsetenv(v);
|
||||
}
|
||||
|
||||
WriteFile(fs::path(orders_.string() + ".shipping.json"), kShippingFixture);
|
||||
|
||||
if (options.gpg) {
|
||||
const fs::path gnupg = work_ / "gnupg";
|
||||
fs::create_directories(gnupg);
|
||||
fs::permissions(gnupg, fs::perms::owner_all, fs::perm_options::replace);
|
||||
::setenv("GNUPGHOME", gnupg.c_str(), 1);
|
||||
// gpg is declared via Requires("tool:gpg") in project.cpp, so a
|
||||
// missing binary skips the suite before this ever runs; an error
|
||||
// HERE is a real failure and should fail loudly.
|
||||
if (std::system("gpg --batch --passphrase '' --quick-gen-key "
|
||||
"'Catcrafts e2e <invoices@e2e.invalid>' "
|
||||
"default default never >/dev/null 2>&1") != 0) {
|
||||
std::println(std::cerr, "e2e: could not create a GPG key");
|
||||
std::exit(1);
|
||||
}
|
||||
::setenv("INVOICE_GPG_KEY", "invoices@e2e.invalid", 1);
|
||||
}
|
||||
|
||||
if (options.mailer) {
|
||||
// The mailer sends sequentially from one thread, so the count-up
|
||||
// cannot race itself.
|
||||
const fs::path sendmail = work_ / "sendmail";
|
||||
WriteFile(sendmail, std::format(
|
||||
"#!/bin/sh\n"
|
||||
"n=1\n"
|
||||
"while [ -e \"{0}/mail-$n.eml\" ]; do n=$((n + 1)); done\n"
|
||||
"cat > \"{0}/mail-$n.eml\"\n", work_.string()));
|
||||
fs::permissions(sendmail,
|
||||
fs::perms::owner_all | fs::perms::group_read
|
||||
| fs::perms::others_read,
|
||||
fs::perm_options::replace);
|
||||
::setenv("MAIL_COMMAND", sendmail.c_str(), 1);
|
||||
::setenv("MAIL_FROM", "Catcrafts <info@catcrafts.net>", 1);
|
||||
}
|
||||
|
||||
for (const auto& [name, value] : options.env) {
|
||||
::setenv(name.c_str(), value.c_str(), 1);
|
||||
}
|
||||
|
||||
std::vector<std::string> argv = {
|
||||
std::move(binary), "--serve", std::to_string(port),
|
||||
std::format("--orders={}", orders_.string()),
|
||||
};
|
||||
for (const std::string& a : options.extraArgs) argv.push_back(a);
|
||||
Spawn(argv);
|
||||
WaitUntilUp();
|
||||
}
|
||||
|
||||
~TestServer() {
|
||||
if (pid_ > 0) {
|
||||
::kill(pid_, SIGTERM);
|
||||
int status = 0;
|
||||
::waitpid(pid_, &status, 0);
|
||||
}
|
||||
std::error_code ec;
|
||||
fs::remove_all(work_, ec);
|
||||
}
|
||||
|
||||
TestServer(const TestServer&) = delete;
|
||||
TestServer& operator=(const TestServer&) = delete;
|
||||
|
||||
const fs::path& Work() const { return work_; }
|
||||
const fs::path& Orders() const { return orders_; }
|
||||
std::string OrdersText() const { return ReadFile(orders_); }
|
||||
|
||||
// One connection per request, like one curl invocation per check in the
|
||||
// shell version: the suites assert responses, not connection reuse (the
|
||||
// keep-alive path has its own tests in Crafter.Network).
|
||||
Crafter::HTTPResponse Request(Crafter::HTTPRequest request) {
|
||||
request.scheme = "http";
|
||||
Crafter::ClientHTTP1 client("127.0.0.1", port_);
|
||||
return client.Send(std::move(request));
|
||||
}
|
||||
|
||||
Crafter::HTTPResponse Get(std::string path) {
|
||||
return Request(Crafter::CreateRequestHTTP("GET", std::move(path), "127.0.0.1"));
|
||||
}
|
||||
|
||||
Crafter::HTTPResponse Head(std::string path) {
|
||||
return Request(Crafter::CreateRequestHTTP("HEAD", std::move(path), "127.0.0.1"));
|
||||
}
|
||||
|
||||
// A form post, exactly what a browser (and `curl -d`) sends.
|
||||
Crafter::HTTPResponse Post(std::string path, std::string body,
|
||||
std::string contentType = "application/x-www-form-urlencoded") {
|
||||
return Request(Crafter::CreateRequestHTTP(
|
||||
"POST", std::move(path), "127.0.0.1",
|
||||
{{ "content-type", std::move(contentType) }}, std::move(body)));
|
||||
}
|
||||
|
||||
std::string Body(std::string path) { return Get(std::move(path)).body; }
|
||||
|
||||
// ── the e2e.sh assertion primitives ──────────────────────────────
|
||||
|
||||
// status <path> <expected> [method] [body]
|
||||
void CheckStatus(std::string path, std::string_view want,
|
||||
std::string_view method = "GET", std::string body = {}) {
|
||||
Crafter::HTTPResponse r;
|
||||
if (method == "POST") r = Post(path, std::move(body));
|
||||
else if (method == "HEAD") r = Head(path);
|
||||
else r = Get(path);
|
||||
Check(r.status == want,
|
||||
std::format("{} {} -> {}", method, path, want),
|
||||
r.status);
|
||||
}
|
||||
|
||||
void BodyHas(std::string path, std::string_view needle, std::string_view label) {
|
||||
Check(Body(std::move(path)).find(needle) != std::string::npos,
|
||||
label, std::format("missing: {}", needle));
|
||||
}
|
||||
|
||||
void BodyLacks(std::string path, std::string_view needle, std::string_view label) {
|
||||
Check(Body(std::move(path)).find(needle) == std::string::npos,
|
||||
label, std::format("unexpectedly present: {}", needle));
|
||||
}
|
||||
|
||||
// header_has <path> <header> <value substring>. The parser lowercases
|
||||
// field names and the values the suites probe are already lowercase on
|
||||
// the wire, so a plain find replaces the shell version's grep -iE.
|
||||
void HeaderHas(std::string path, std::string_view header,
|
||||
std::string_view valuePart, std::string_view label) {
|
||||
const auto r = Get(std::move(path));
|
||||
const auto it = r.headers.find(std::string(header));
|
||||
Check(it != r.headers.end() && it->second.find(valuePart) != std::string::npos,
|
||||
label,
|
||||
it == r.headers.end() ? std::format("no {} header", header) : it->second);
|
||||
}
|
||||
|
||||
// Poll until the body of `path` contains `needle`, for the settle-time
|
||||
// checks (reconciler cadence, mailer sweeps). Returns the final body.
|
||||
std::string WaitForBody(const std::string& path, std::string_view needle,
|
||||
std::int32_t tries = 40) {
|
||||
for (std::int32_t i = 0; i < tries; ++i) {
|
||||
std::string body = Body(path);
|
||||
if (body.find(needle) != std::string::npos) return body;
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(250));
|
||||
}
|
||||
return Body(path);
|
||||
}
|
||||
|
||||
// Open shop or coming-soon? The pricing blob (data-cc) exists only on the
|
||||
// real order form, so its presence is the probe.
|
||||
bool ShopOpen() { return Body("/shop/fp6-pmos").find("data-cc=") != std::string::npos; }
|
||||
|
||||
private:
|
||||
void Spawn(const std::vector<std::string>& argv) {
|
||||
log_ = work_ / "server.log";
|
||||
std::vector<char*> cargv;
|
||||
cargv.reserve(argv.size() + 1);
|
||||
for (const std::string& a : argv) cargv.push_back(const_cast<char*>(a.c_str()));
|
||||
cargv.push_back(nullptr);
|
||||
|
||||
pid_ = ::fork();
|
||||
if (pid_ == 0) {
|
||||
const int fd = ::open(log_.c_str(), O_CREAT | O_WRONLY | O_TRUNC, 0644);
|
||||
if (fd >= 0) {
|
||||
::dup2(fd, 1);
|
||||
::dup2(fd, 2);
|
||||
::close(fd);
|
||||
}
|
||||
::execv(cargv[0], cargv.data());
|
||||
::_exit(127);
|
||||
}
|
||||
if (pid_ < 0) {
|
||||
std::println(std::cerr, "e2e: fork failed");
|
||||
std::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for the listener rather than sleeping a fixed amount: a fixed
|
||||
// sleep is either too short on a loaded machine or wasted time on a
|
||||
// fast one.
|
||||
void WaitUntilUp() {
|
||||
for (std::int32_t i = 0; i < 100; ++i) {
|
||||
// The child exiting early (bad flag, port in use) must not turn
|
||||
// into a 10-second wait on a listener that will never appear.
|
||||
int status = 0;
|
||||
if (::waitpid(pid_, &status, WNOHANG) == pid_) {
|
||||
pid_ = -1;
|
||||
break;
|
||||
}
|
||||
try {
|
||||
if (Get("/api/healthz").status == "200") return;
|
||||
} catch (...) {
|
||||
// Not listening yet.
|
||||
}
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
}
|
||||
std::println(std::cerr, "e2e: server did not come up on {}", port_);
|
||||
std::println(std::cerr, "{}", ReadFile(log_));
|
||||
std::exit(1);
|
||||
}
|
||||
|
||||
std::uint16_t port_ = 0;
|
||||
pid_t pid_ = -1;
|
||||
fs::path work_;
|
||||
fs::path orders_;
|
||||
fs::path log_;
|
||||
};
|
||||
|
||||
} // namespace Catcrafts::E2e
|
||||
Loading…
Reference in a new issue