1524 lines
86 KiB
C++
1524 lines
86 KiB
C++
/*
|
||
catcrafts.net
|
||
Copyright (C) 2026 Catcrafts
|
||
|
||
The source code of this website is made available for viewing purposes only.
|
||
No permission is granted to copy, modify, distribute, or create derivative works.
|
||
*/
|
||
|
||
// catcrafts-server — the native product.
|
||
//
|
||
// Serves the server-rendered pages (crawlers and no-JS clients get real HTML),
|
||
// runs the shop — orders, the bunq payment rail, the reconciler — and doubles
|
||
// as the test harness for Catcrafts.Shared.
|
||
//
|
||
// The harness half is not filler. Catcrafts.Shared is the security boundary
|
||
// for every piece of markup the site emits, and it is target-neutral precisely
|
||
// so it can be tested somewhere with a debugger, sanitizers and a normal test
|
||
// loop instead of only inside a wasm module in a browser tab. `--selftest`
|
||
// is how the shared code gets executed rather than merely compiled.
|
||
//
|
||
// crafter-build -- --product=server && ./bin/Catcrafts.Server-*/catcrafts-server --selftest
|
||
|
||
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);
|
||
}
|
||
|
||
void CheckEq(const Html::SafeHtml& actual, std::string_view expected, std::string_view what) {
|
||
Check(actual.View() == expected, what, actual.View());
|
||
}
|
||
|
||
void RunSelfTest() {
|
||
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");
|
||
}
|
||
|
||
// 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.
|
||
void RunMediaSelfTest() {
|
||
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");
|
||
}
|
||
}
|
||
|
||
// 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.
|
||
void RunMarkdownSelfTest() {
|
||
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");
|
||
}
|
||
|
||
// 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.
|
||
void RunPostSelfTest() {
|
||
// ── 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");
|
||
}
|
||
}
|
||
|
||
void RunJsonSelfTest() {
|
||
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");
|
||
}
|
||
|
||
void RunFormSelfTest() {
|
||
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");
|
||
|
||
// 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");
|
||
}
|
||
|
||
void RunMoneySelfTest() {
|
||
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("CA") == Zone::World, "zone: world");
|
||
|
||
// ── order totals ──────────────────────────────────────────────────
|
||
Check(ZoneShipping(1500, 2500, 5500, "NL") == 1500, "ship: NL zone");
|
||
Check(ZoneShipping(1500, 2500, 5500, "DE") == 2500, "ship: EU zone");
|
||
Check(ZoneShipping(1500, 2500, 5500, "CA") == 5500, "ship: world zone");
|
||
|
||
// NL: gross + shipping, VAT included in both.
|
||
auto nl = ComputeTotals(58000, 1, 1500, "NL");
|
||
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 ca = ComputeTotals(58000, 1, 5500, "CA");
|
||
Check(ca.goods == 47934 && ca.shipping == 5500 && ca.total == 53434,
|
||
"totals: export");
|
||
Check(!ca.vatIncluded && ca.vatCharged == 0, "totals: export carries no VAT");
|
||
|
||
// Quantity: the export net is derived from the LINE total, not per unit —
|
||
// per-unit rounding times qty would differ by a cent here, and the JS
|
||
// preview mirrors this exact formula.
|
||
auto ca2 = ComputeTotals(57500, 2, 5500, "CA");
|
||
Check(ca2.goods == NetFromGross(115000), "totals: qty nets the line, not the unit");
|
||
Check(ca2.goods == 95041, "totals: 2× green export net exact");
|
||
auto nl2 = ComputeTotals(57500, 3, 1500, "NL");
|
||
Check(nl2.goods == 172500 && nl2.total == 174000, "totals: qty multiplies gross");
|
||
|
||
// ── the compiled-in catalogue ─────────────────────────────────────
|
||
// Content is code now; these assertions are the contract the shop pages
|
||
// rely on, checked against the actual shipped data.
|
||
{
|
||
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.
|
||
{
|
||
auto pp = Views::RenderProduct(pr, Rates{});
|
||
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");
|
||
}
|
||
}
|
||
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.
|
||
{
|
||
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");
|
||
}
|
||
|
||
// ── the Sendcloud response parser ─────────────────────────────────
|
||
{
|
||
const auto table = Server::ParseSendcloudMethods(R"({"shipping_methods":[
|
||
{"name":"Other Method","countries":[{"iso_2":"NL","price":1.00}]},
|
||
{"name":"DHL For You Home","countries":[
|
||
{"iso_2":"NL","price":6.25},
|
||
{"iso_2":"DE","price":8.20},
|
||
{"iso_2":"CA","price":42.50},
|
||
{"iso_2":"XX","price":0},
|
||
{"iso_2":"TOOLONG","price":5.00}]}]})", "DHL For You");
|
||
Check(table.method == "DHL For You Home", "sendcloud: method matched by substring");
|
||
Check(table.Find("NL") == 625, "sendcloud: NL price to cents");
|
||
Check(table.Find("DE") == 820, "sendcloud: 8.20 rounds exactly");
|
||
Check(table.Find("CA") == 4250, "sendcloud: CA price");
|
||
Check(table.Find("XX") == 0, "sendcloud: zero price dropped");
|
||
Check(table.Find("TOOLONG") == 0, "sendcloud: malformed iso dropped");
|
||
Check(Server::ParseSendcloudMethods("garbage", "x").perCountry.empty(),
|
||
"sendcloud: malformed payload yields nothing");
|
||
|
||
// Comma-separated merge: courier for Europe, post for the world; the
|
||
// earlier method keeps any country both cover.
|
||
const auto merged = Server::ParseSendcloudMethods(R"({"shipping_methods":[
|
||
{"name":"DPD Home","countries":[
|
||
{"iso_2":"NL","price":7.13},{"iso_2":"DE","price":10.49}]},
|
||
{"name":"PostNL Parcels non-EU","countries":[
|
||
{"iso_2":"CA","price":23.95},{"iso_2":"US","price":17.94},
|
||
{"iso_2":"DE","price":99.99}]}]})",
|
||
"DPD Home, PostNL Parcels non-EU");
|
||
Check(merged.Find("NL") == 713 && merged.Find("CA") == 2395,
|
||
"sendcloud: merged table covers both methods");
|
||
Check(merged.Find("DE") == 1049,
|
||
"sendcloud: earlier method wins a shared country");
|
||
Check(merged.method == "DPD Home + PostNL Parcels non-EU",
|
||
"sendcloud: merged method names recorded");
|
||
}
|
||
|
||
// ── indicative conversion ─────────────────────────────────────────
|
||
// €580.00 at 1.0834 USD/EUR = $628.37 -> 628 whole units.
|
||
Check(ConvertIndicative(58000, 1'083'400) == 628, "fx: converts to whole units");
|
||
Check(ConvertIndicative(58000, 1'000'000) == 580, "fx: identity rate");
|
||
auto ca$ = CurrencyFor("CA");
|
||
Check(ca$.has_value() && ca$->code == "CAD", "fx: CA -> CAD");
|
||
Check(!CurrencyFor("DE").has_value(), "fx: euro country has no conversion");
|
||
Check(!CurrencyFor("XX").has_value(), "fx: unknown country has no conversion");
|
||
if (ca$) {
|
||
Check(FormatIndicative(*ca$, 920) == "≈ CA$920", "fx: display form");
|
||
}
|
||
|
||
// ── order tokens and references ───────────────────────────────────
|
||
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");
|
||
|
||
// ── the wire-amount parser (bunq responses) ───────────────────────
|
||
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");
|
||
}
|
||
|
||
// ── the invoice builder ───────────────────────────────────────────
|
||
{
|
||
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 = "CA";
|
||
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 = "CA";
|
||
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");
|
||
}
|
||
|
||
// ── 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");
|
||
}
|
||
|
||
std::string ReadFile(const std::filesystem::path& p) {
|
||
std::ifstream in(p, std::ios::binary);
|
||
if (!in) return {};
|
||
std::ostringstream buf;
|
||
buf << in.rdbuf();
|
||
return buf.str();
|
||
}
|
||
|
||
// Load content/ from disk. The wasm host reads the same bytes out of the VFS
|
||
// instead; the loaders are shared, so only the source of the bytes differs.
|
||
// Content loader for the CLI modes (--render, --routes, --sitemap, --feed).
|
||
//
|
||
// Must stay in step with Server::LoadContent, which the --serve path uses. They
|
||
// are separate because the CLI wants a value it can pass around while the server
|
||
// keeps process-wide state — but a field added to one and forgotten in the other
|
||
// shows up as content silently missing from exactly one code path, which is how
|
||
// products came to be absent from --routes and --sitemap while the live server
|
||
// served them fine.
|
||
Views::SiteContent LoadContent(const std::filesystem::path& root) {
|
||
Views::SiteContent c;
|
||
c.projects = Content::Projects();
|
||
c.products = Content::Products();
|
||
c.legal = Content::LegalPages();
|
||
c.demos = Content::Demos();
|
||
c.posts = LoadPosts(ReadFile(root / "posts.json"));
|
||
c.rates = LoadRates(ReadFile(root / "rates.json"));
|
||
return c;
|
||
}
|
||
|
||
} // namespace
|
||
|
||
int main(int argc, char** argv) {
|
||
const std::vector<std::string_view> args(argv + 1, argv + argc);
|
||
const auto has = [&](std::string_view f) {
|
||
return std::find(args.begin(), args.end(), f) != args.end();
|
||
};
|
||
|
||
if (has("--selftest")) {
|
||
RunSelfTest();
|
||
RunJsonSelfTest();
|
||
RunFormSelfTest();
|
||
RunMoneySelfTest();
|
||
RunMediaSelfTest();
|
||
RunMarkdownSelfTest();
|
||
RunPostSelfTest();
|
||
if (failures == 0) {
|
||
std::println("Catcrafts.Shared self-test: all assertions passed");
|
||
return 0;
|
||
}
|
||
std::println(std::cerr, "Catcrafts.Shared self-test: {} failure(s)", failures);
|
||
return 1;
|
||
}
|
||
|
||
// --render <path>: emit the full server-rendered document for a route.
|
||
//
|
||
// This is the SSR path in miniature, and it is how the markup gets
|
||
// inspected without a browser: same renderers, same content files, same
|
||
// output the server will eventually put on the wire.
|
||
if (args.size() >= 2 && args[0] == "--render") {
|
||
const Views::SiteContent content = LoadContent("content");
|
||
const Route route = ParseRoute(args[1]);
|
||
const Views::RenderedPage page = Views::RenderRoute(route, content);
|
||
std::print("{}", Views::RenderDocument(
|
||
page,
|
||
Views::RenderNav(NavKindFor(route.kind)),
|
||
Views::RenderFooter(),
|
||
/*bootScripts=*/"", // no wasm on a plain server render
|
||
/*cssHref=*/"/styles.css"));
|
||
return 0;
|
||
}
|
||
|
||
// --sitemap / --feed: generated from the same route table and Post model
|
||
// the pages use, so they cannot drift from what the site actually serves.
|
||
// The checked-in sitemap.xml this replaces still listed three blog posts
|
||
// that no longer exist.
|
||
//
|
||
// Html::Escape's output is valid XML text: & < > " are
|
||
// shared with XML, and it emits an apostrophe as the numeric reference
|
||
// ' rather than the HTML-only '. So no separate XML escaper.
|
||
if (has("--sitemap")) {
|
||
const Views::SiteContent content = LoadContent("content");
|
||
std::print("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
|
||
"<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n");
|
||
for (std::string_view p : SitemapPaths()) {
|
||
std::print(" <url><loc>https://catcrafts.net{}</loc></url>\n",
|
||
Html::Escape(p).Str());
|
||
}
|
||
// Product URLs come from the loaded catalogue rather than a second
|
||
// hardcoded list, so the sitemap cannot advertise a product that does
|
||
// not exist or miss one that does.
|
||
for (const Product& pr : content.products) {
|
||
std::print(" <url><loc>https://catcrafts.net/shop/{}</loc></url>\n",
|
||
Html::Escape(pr.slug).Str());
|
||
}
|
||
// Same rule as the served sitemap: only posts that actually have a
|
||
// page. Both must agree, because this is the copy baked into the wasm
|
||
// bundle and that one is what a crawler fetches.
|
||
for (const Post& po : content.posts) {
|
||
if (!po.HasPage()) continue;
|
||
std::print(" <url><loc>https://catcrafts.net/posts/{}</loc></url>\n",
|
||
Html::Escape(po.slug).Str());
|
||
}
|
||
std::print("</urlset>\n");
|
||
return 0;
|
||
}
|
||
|
||
if (has("--feed")) {
|
||
const Views::SiteContent content = LoadContent("content");
|
||
std::print("{}", Views::RenderAtomFeed(content.posts));
|
||
return 0;
|
||
}
|
||
|
||
// --routes: status + title for every route, for a quick smoke check.
|
||
if (has("--routes")) {
|
||
const Views::SiteContent content = LoadContent("content");
|
||
for (std::string_view p : { "/", "/about", "/shop", "/shop/fp6-pmos", "/shop/nope",
|
||
"/order/0123456789abcdef0123456789abcdef",
|
||
"/order/not-a-token",
|
||
"/legal/privacy", "/legal/imprint",
|
||
"/legal/terms", "/legal/nope",
|
||
"/projects", "/posts", "/posts/nope", "/demos",
|
||
"/demos/raytracer", "/demos/nope", "/demo",
|
||
"/projects/", "/blog", "/blog/hello-world", "/nope" }) {
|
||
const Route r = ParseRoute(p);
|
||
const Views::RenderedPage page = Views::RenderRoute(r, content);
|
||
std::println("{:<22} status={} bytes={:<6} title={}",
|
||
p, page.status, page.main.Size(), page.meta.title);
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
// --serve [port] [--content=DIR] [--webroot=DIR]
|
||
//
|
||
// Plaintext HTTP/1.1 for Caddy to reverse-proxy to; see
|
||
// Catcrafts.Server-Http.cpp for why not HTTP/3.
|
||
//
|
||
// Both directories are options rather than fixed paths because the
|
||
// development layout and the deployed layout differ: in the repo the
|
||
// content sits in ./content and the wasm bundle under ./bin/Catcrafts.Net-*/,
|
||
// while on the server the content is installed next to the binary and the
|
||
// bundle IS the webroot Caddy serves.
|
||
if (!args.empty() && args[0] == "--serve") {
|
||
std::uint16_t port = 8081;
|
||
std::filesystem::path contentDir = "content";
|
||
std::filesystem::path webroot;
|
||
// Default alongside the content in dev; the systemd unit points this at
|
||
// /var/lib/catcrafts, which is deliberately NOT the web root — that
|
||
// directory is publicly served and wiped by rsync --delete each deploy.
|
||
std::filesystem::path ordersPath = "orders.jsonl";
|
||
// Payment rail selection. Flags beat environment beats default. The
|
||
// default is "whichever provider has a key, off otherwise" so a box
|
||
// with no credentials serves the whole site minus checkout instead of
|
||
// refusing to start. Mollie outranks bunq: bunq.me's per-method limits
|
||
// (€500/card, nothing for non-EU buyers) disqualified it as the
|
||
// checkout; the client is kept for a possible future account sweep.
|
||
const char* mollieKey = std::getenv("MOLLIE_API_KEY");
|
||
const char* bunqKey = std::getenv("BUNQ_API_KEY");
|
||
std::string railMode = mollieKey && *mollieKey ? "mollie"
|
||
: bunqKey && *bunqKey ? "bunq"
|
||
: "off";
|
||
bool bunqSandbox = [] {
|
||
const char* v = std::getenv("BUNQ_SANDBOX");
|
||
return v && std::string_view(v) == "1";
|
||
}();
|
||
std::filesystem::path railState;
|
||
std::string redirectBase = [] {
|
||
const char* v = std::getenv("ORDER_REDIRECT_BASE");
|
||
return v && *v ? std::string(v) : std::string("https://catcrafts.net");
|
||
}();
|
||
|
||
for (std::size_t i = 1; i < args.size(); ++i) {
|
||
const std::string_view a = args[i];
|
||
if (a.starts_with("--content=")) {
|
||
contentDir = a.substr(10);
|
||
} else if (a.starts_with("--webroot=")) {
|
||
webroot = a.substr(10);
|
||
} else if (a.starts_with("--orders=")) {
|
||
ordersPath = a.substr(9);
|
||
} else if (a.starts_with("--rail=")) {
|
||
railMode = a.substr(7);
|
||
} else if (a.starts_with("--bunq=")) {
|
||
railMode = a.substr(7); // legacy alias for --rail=
|
||
} else if (a.starts_with("--rail-state=")) {
|
||
railState = a.substr(13);
|
||
} else if (a.starts_with("--bunq-state=")) {
|
||
railState = a.substr(13); // legacy alias for --rail-state=
|
||
} else if (a.starts_with("--redirect-base=")) {
|
||
redirectBase = a.substr(16);
|
||
} else {
|
||
std::uint32_t parsed = 0;
|
||
if (std::from_chars(a.data(), a.data() + a.size(), parsed).ec == std::errc{}
|
||
&& parsed > 0 && parsed <= 65535) {
|
||
port = static_cast<std::uint16_t>(parsed);
|
||
} else {
|
||
std::println(std::cerr, "--serve: unrecognised argument '{}'", a);
|
||
return 2;
|
||
}
|
||
}
|
||
}
|
||
|
||
// The bundle's index.html supplies the <script> tags with their
|
||
// per-build ?v= cache buster, which is why they are read rather than
|
||
// hardcoded — a hardcoded tag would silently serve a stale module.
|
||
//
|
||
// A missing bundle is NOT fatal: every route except /demo renders
|
||
// completely without the wasm, so the site degrades to plain SSR
|
||
// instead of refusing to start.
|
||
std::filesystem::path bundleIndex;
|
||
std::error_code ec;
|
||
if (!webroot.empty()) {
|
||
bundleIndex = webroot / "index.html";
|
||
if (!std::filesystem::exists(bundleIndex, ec)) bundleIndex.clear();
|
||
} else if (std::filesystem::is_directory("bin", ec)) {
|
||
for (const auto& e : std::filesystem::directory_iterator("bin", ec)) {
|
||
if (e.is_directory() && e.path().filename().string().starts_with("Catcrafts.Net-")) {
|
||
bundleIndex = e.path() / "index.html";
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
if (bundleIndex.empty()) {
|
||
std::println(std::cerr,
|
||
"catcrafts-server: no wasm bundle index.html found; "
|
||
"/demo will render without the renderer");
|
||
}
|
||
|
||
if (!std::filesystem::is_directory(contentDir, ec)) {
|
||
std::println(std::cerr, "catcrafts-server: content directory '{}' not found",
|
||
contentDir.string());
|
||
return 2;
|
||
}
|
||
|
||
Server::SetOrdersPath(ordersPath);
|
||
Server::LoadContent(contentDir, bundleIndex);
|
||
// Refuse to serve an empty catalogue: it almost always means the
|
||
// content path is wrong or a JSON file is malformed, and a silently
|
||
// empty projects page looks like a design choice rather than a bug.
|
||
if (Server::ContentProjectCount() == 0) {
|
||
std::println(std::cerr,
|
||
"catcrafts-server: no projects loaded from '{}' — refusing to start",
|
||
contentDir.string());
|
||
return 2;
|
||
}
|
||
|
||
// The rail. State (bunq session context, or the fake rail's paid
|
||
// marker; Mollie needs none) defaults next to the orders file — same
|
||
// directory, same lifecycle, same backup.
|
||
if (railState.empty()) {
|
||
railState = ordersPath;
|
||
railState += (railMode == "fake") ? ".fake-paid" : ".bunq-state.json";
|
||
}
|
||
Server::RailConfig railCfg;
|
||
railCfg.mode = railMode;
|
||
railCfg.apiKey = railMode == "mollie" ? (mollieKey ? mollieKey : "")
|
||
: railMode == "bunq" ? (bunqKey ? bunqKey : "")
|
||
: "";
|
||
railCfg.sandbox = bunqSandbox;
|
||
railCfg.statePath = railState;
|
||
railCfg.redirectBase = redirectBase;
|
||
std::unique_ptr<Server::PaymentRail> rail = Server::MakeRail(railCfg);
|
||
if ((railMode == "mollie" || railMode == "bunq") && railCfg.apiKey.empty()) {
|
||
std::println(std::cerr,
|
||
"catcrafts-server: --rail={} but its API key env is not set — "
|
||
"refusing to start with a rail that cannot work", railMode);
|
||
return 2;
|
||
}
|
||
|
||
Server::ConfigurePayments(std::move(rail), redirectBase);
|
||
|
||
// Invoice signing: the GPG key uid/fingerprint; GNUPGHOME decides the
|
||
// keyring. Unset means unsigned dev invoices with a visible marker.
|
||
if (const char* v = std::getenv("INVOICE_GPG_KEY"); v && *v) {
|
||
Server::ConfigureInvoicing(v);
|
||
}
|
||
|
||
// Order email: a sendmail-compatible command ("msmtp -t" on the
|
||
// server) that reads the message on stdin and takes the recipient
|
||
// from its headers. Unset means no email is sent — the order page
|
||
// and invoice download remain the buyer's receipt.
|
||
{
|
||
Server::MailConfig mailCfg;
|
||
if (const char* v = std::getenv("MAIL_COMMAND"); v && *v) mailCfg.command = v;
|
||
if (const char* v = std::getenv("MAIL_FROM"); v && *v) mailCfg.from = v;
|
||
Server::ConfigureMail(std::move(mailCfg));
|
||
}
|
||
|
||
// Sendcloud is optional: without credentials the compiled-in zone
|
||
// table prices all shipping, which is exactly how dev and e2e run.
|
||
// With credentials the refresh thread fetches per-country rates.
|
||
Server::ShippingConfig shipCfg;
|
||
if (const char* v = std::getenv("SENDCLOUD_PUBLIC_KEY")) shipCfg.publicKey = v;
|
||
if (const char* v = std::getenv("SENDCLOUD_SECRET_KEY")) shipCfg.secretKey = v;
|
||
if (const char* v = std::getenv("SENDCLOUD_METHOD")) shipCfg.methodName = v;
|
||
shipCfg.cachePath = ordersPath;
|
||
shipCfg.cachePath += ".shipping.json";
|
||
Server::ConfigureShipping(shipCfg);
|
||
|
||
return Server::Serve(port);
|
||
}
|
||
|
||
// --orders [FILE]: the ledger, human-shaped. And the manual transitions —
|
||
// the escape hatch for a payment bunq confirmed out-of-band (or a refund):
|
||
// --orders FILE --mark-paid TOKEN | --mark-shipped TOKEN | --cancel TOKEN
|
||
if (!args.empty() && args[0] == "--orders") {
|
||
std::filesystem::path file = "orders.jsonl";
|
||
std::string markPaid, markShipped, cancel;
|
||
for (std::size_t i = 1; i < args.size(); ++i) {
|
||
const std::string_view a = args[i];
|
||
auto next = [&]() -> std::string {
|
||
return (i + 1 < args.size()) ? std::string(args[++i]) : std::string{};
|
||
};
|
||
if (a == "--mark-paid") markPaid = next();
|
||
else if (a == "--mark-shipped") markShipped = next();
|
||
else if (a == "--cancel") cancel = next();
|
||
else file = a;
|
||
}
|
||
Server::SetOrdersPath(file);
|
||
|
||
auto transition = [&](const std::string& token, std::string_view status) -> int {
|
||
auto order = Server::FindOrder(token);
|
||
if (!order) {
|
||
std::println(std::cerr, "no such order: {}", token);
|
||
return 1;
|
||
}
|
||
const std::string now = std::format(
|
||
"{:%FT%TZ}", std::chrono::floor<std::chrono::seconds>(
|
||
std::chrono::system_clock::now()));
|
||
if (!Server::AppendOrderStatus(token, status, now)) {
|
||
std::println(std::cerr, "could not append to {}", file.string());
|
||
return 1;
|
||
}
|
||
if (status == "paid") Server::AssignInvoiceNumber(token, now);
|
||
std::println("{}: {} -> {}", order->reference, order->status, status);
|
||
return 0;
|
||
};
|
||
if (!markPaid.empty()) return transition(markPaid, "paid");
|
||
if (!markShipped.empty()) return transition(markShipped, "shipped");
|
||
if (!cancel.empty()) return transition(cancel, "cancelled");
|
||
|
||
const auto orders = Server::ListOrders();
|
||
std::println("orders: {}", orders.size());
|
||
if (orders.empty()) return 0;
|
||
std::println("");
|
||
std::println("{:<10} {:<17} {:>10} {:<3} {:<8} {:>3} {:<11} {:<20} {}",
|
||
"reference", "status", "total", "cc", "colour", "qty", "via",
|
||
"created", "token");
|
||
for (const auto& o : orders) {
|
||
std::println("{:<10} {:<17} {:>10} {:<3} {:<8} {:>3} {:<11} {:<20} {}",
|
||
o.reference, o.status, Money::FormatMinor(o.totalMinor),
|
||
o.buyer.country, o.color.empty() ? "-" : o.color,
|
||
o.quantity, o.paidVia.empty() ? "-" : o.paidVia,
|
||
o.createdAt, o.token);
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
std::println("catcrafts-server: --selftest | --render <path> | --routes | --sitemap | --feed\n"
|
||
" --serve [port] [--content=DIR] [--webroot=DIR] [--orders=FILE]\n"
|
||
" [--rail=off|fake|mollie|bunq] [--rail-state=FILE] [--redirect-base=URL]\n"
|
||
" --orders [FILE] [--mark-paid TOKEN | --mark-shipped TOKEN | --cancel TOKEN]\n"
|
||
"\n"
|
||
"environment: MOLLIE_API_KEY (test_… or live_…), BUNQ_API_KEY, BUNQ_SANDBOX=1,\n"
|
||
" ORDER_REDIRECT_BASE, SENDCLOUD_PUBLIC_KEY/SECRET_KEY/METHOD,\n"
|
||
" INVOICE_GPG_KEY, MAIL_COMMAND (e.g. 'msmtp -t'), MAIL_FROM");
|
||
return 0;
|
||
}
|