This commit is contained in:
parent
33c68c2f44
commit
749f525f83
44 changed files with 5380 additions and 3532 deletions
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();
|
||||
}
|
||||
Loading…
Reference in a new issue