All checks were successful
Deploy / build-deploy (push) Successful in 4m11s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
482 lines
26 KiB
C++
482 lines
26 KiB
C++
/*
|
|
catcrafts.net
|
|
Copyright (C) 2026 Catcrafts
|
|
|
|
The source code of this website is made available for viewing purposes only.
|
|
No permission is granted to copy, modify, distribute, or create derivative works.
|
|
*/
|
|
|
|
// The 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");
|
|
|
|
// Donations paid through the shop join the bank-side ones in ONE row —
|
|
// the reader has no use for a split by collection channel. The income
|
|
// total and the net move with them.
|
|
{
|
|
const Views::RenderedPage both = Views::RenderFinancials(2, 113745, fin, 2, 5000);
|
|
Check(both.main.View().find("data-fin-donations-count=\"5\"") != std::string_view::npos
|
|
&& both.main.View().find("data-fin-donations-minor=\"9500\"")
|
|
!= std::string_view::npos,
|
|
"financials: shop and bank donations sum into one row");
|
|
Check(both.main.View().find("Donations (5)") != std::string_view::npos,
|
|
"financials: the donation row counts both sources");
|
|
// Net = (4500 + 5000 + 113745) - 234800 = -111555.
|
|
Check(both.main.View().find("data-fin-net-minor=\"-111555\"") != std::string_view::npos,
|
|
"financials: the net includes shop donations");
|
|
}
|
|
// A shop donation shows the moment it is paid, even before any bank
|
|
// figures exist — it is live from the order ledger, like sales. The
|
|
// income total still waits for the bank side: a total missing half its
|
|
// inputs is not a total.
|
|
{
|
|
const Views::RenderedPage shopOnly = Views::RenderFinancials(0, 0, Financials{}, 1, 2500);
|
|
Check(shopOnly.main.View().find("data-fin-donations-count=\"1\"") != std::string_view::npos
|
|
&& shopOnly.main.View().find("data-fin-donations-minor=\"2500\"")
|
|
!= std::string_view::npos,
|
|
"financials: a shop donation publishes without bank figures");
|
|
Check(shopOnly.main.View().find("Donations (1)") != std::string_view::npos,
|
|
"financials: and renders its row");
|
|
// The Income SECTION heading always renders; what must wait for the
|
|
// bank side is the ruled-off total row (and the net).
|
|
Check(shopOnly.main.View().find(R"(<tr class="fin-total"><th scope="row">Income</th>)")
|
|
== std::string_view::npos
|
|
&& shopOnly.main.View().find("data-fin-net-minor") == std::string_view::npos,
|
|
"financials: no income total or net while the bank side is unpublished");
|
|
}
|
|
|
|
// 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";
|
|
// A paid donation is income but not a sale: it must land in the donation
|
|
// pair, or the page would book the same euro as a sale.
|
|
Server::OrderRecord gift;
|
|
gift.totalMinor = 2500;
|
|
gift.donation = true;
|
|
gift.paidAt = "2026-08-17T00:00:00Z";
|
|
gift.status = "paid";
|
|
const std::array<Server::OrderRecord, 5> orders{ paid, waiting, refunded, shipped, gift };
|
|
const Server::SalesSummary sum = Server::SummarizeSales(orders);
|
|
Check(sum.count == 3 && sum.totalMinor == 56330 + 56930 + 200,
|
|
"financials: sales count ever-paid orders only, donations excluded");
|
|
Check(sum.donationCount == 1 && sum.donationsMinor == 2500,
|
|
"financials: a paid shop donation folds into the donation pair");
|
|
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");
|
|
|
|
// The same payload with one field swapped, so every case below differs
|
|
// from the parsing case above by exactly the thing under test.
|
|
auto payloadWith = [](std::string_view amountObject, std::string_view alias) {
|
|
std::string out;
|
|
out += R"({"NotificationUrl":{"category":"MUTATION","object":{"Payment":{)";
|
|
out += R"("id":4823,"created":"2026-08-14 09:31:02.123456",)";
|
|
out += R"("monetary_account_id":9911,"amount":)";
|
|
out += amountObject;
|
|
out += R"(,"description":"Thanks for imsd!","counterparty_alias":)";
|
|
out += alias;
|
|
out += R"(}}}})";
|
|
return out;
|
|
};
|
|
constexpr std::string_view kDonorAlias =
|
|
R"({"iban":"NL55BUNQ2025123456","display_name":"A Donor"})";
|
|
|
|
// FindPaymentObject matches on the SHAPE — an amount object carrying a
|
|
// value, plus an id — and never looks at what the value SAYS. So a
|
|
// locale-mangled or hostile amount reaches the parser inside an otherwise
|
|
// perfectly well-formed mutation, and the refusal has to happen here. If
|
|
// it ever softened to a zero fallback the mutation would be recorded as
|
|
// seen, permanently deduped, with the money dropped from the totals and
|
|
// nothing in the operator log to say so.
|
|
Check(!Server::ParseBunqMutation(
|
|
payloadWith(R"({"currency":"EUR","value":"25,00"})", kDonorAlias))
|
|
.has_value(),
|
|
"bunq: a comma decimal is refused rather than read as zero");
|
|
Check(!Server::ParseBunqMutation(
|
|
payloadWith(R"({"currency":"EUR","value":"1.234"})", kDonorAlias))
|
|
.has_value(),
|
|
"bunq: a third fraction digit is refused rather than truncated");
|
|
Check(!Server::ParseBunqMutation(
|
|
payloadWith(R"({"currency":"EUR","value":"abc"})", kDonorAlias))
|
|
.has_value(),
|
|
"bunq: a non-numeric amount is refused");
|
|
|
|
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");
|
|
|
|
// A payload with no "currency" key at all still has the shape the parser
|
|
// needs, so it parses — and is then refused by the classifier, which is
|
|
// where the euro-only rule lives. Nothing reaches a euro total on the
|
|
// strength of a field that was never sent.
|
|
const auto noCurrency =
|
|
Server::ParseBunqMutation(payloadWith(R"({"value":"25.00"})", kDonorAlias));
|
|
Check(noCurrency && noCurrency->amountMinor == 2500 && noCurrency->currency.empty(),
|
|
"bunq: an amount with no currency still parses");
|
|
Check(noCurrency && Server::ClassifyMutation(*noCurrency, rules).group.empty(),
|
|
"bunq: an unstated currency is never assumed to be euro");
|
|
|
|
// bunq's other alias flavour nests the IBAN one level down, under
|
|
// "labelMonetaryAccount". If that fallback broke, the IBAN would come
|
|
// back empty, the owner's own transfer INTO the donation account would
|
|
// stop matching its ignore rule, and the donation-account default would
|
|
// publish the owner's own money as a stranger's gift — on the one page
|
|
// whose entire promise is that the number is true.
|
|
const auto nested = Server::ParseBunqMutation(payloadWith(
|
|
R"({"currency":"EUR","value":"25.00"})",
|
|
R"({"labelMonetaryAccount":{"iban":"NL01OWNSELF0000000","display_name":"Self"}})"));
|
|
Check(nested && nested->counterpartyIban == "NL01OWNSELF0000000",
|
|
"bunq: the nested alias flavour still yields an iban");
|
|
Check(nested && Server::ClassifyMutation(*nested, rules).group == "ignore",
|
|
"bunq: the owner's own transfer in is ignored, whichever alias shape carries it");
|
|
|
|
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");
|
|
|
|
// A REFUNDED gift. Reachable because an explicit rule may name a group
|
|
// outright, so "donations" is not the exclusive property of the
|
|
// incoming-only account default tested above.
|
|
const Server::FinancialRules donationRules = Server::LoadFinancialRules(
|
|
R"({"rules":[{"iban":"NL55BUNQ2025123456","group":"donations"}]})");
|
|
Server::BankMutation giftBack = *m;
|
|
giftBack.amountMinor = -1000;
|
|
const Server::MutationClass backClass =
|
|
Server::ClassifyMutation(giftBack, donationRules);
|
|
Check(backClass.group == "donations",
|
|
"bunq: an explicit rule can classify outgoing money as a donation");
|
|
// The count follows money IN, never money out: 2500 - 1000 = 1500, and
|
|
// the one person who gave still gave. Decrementing here would put the
|
|
// published donor count below the number of people who actually donated,
|
|
// and the weekly reconciliation folds through this same function — it
|
|
// would reproduce the wrong figure rather than correct it.
|
|
Financials gifts;
|
|
gifts.donationsMinor = 2500;
|
|
gifts.donationCount = 1;
|
|
Server::ApplyMutation(gifts, backClass, giftBack);
|
|
Check(gifts.donationsMinor == 1500 && gifts.donationCount == 1,
|
|
"bunq: a refunded gift reduces the total and leaves the count alone");
|
|
|
|
// Two expenses under different labels are two rows, in first-seen order.
|
|
// Merging them would hide what the money went on behind one bigger
|
|
// number, which is the opposite of what this page is for.
|
|
Server::BankMutation supplier;
|
|
supplier.currency = "EUR";
|
|
supplier.amountMinor = -5000;
|
|
supplier.counterpartyIban = "DE02SUPPLIER000000";
|
|
supplier.created = "2026-08-16";
|
|
const Server::MutationClass supplierClass = Server::ClassifyMutation(supplier, rules);
|
|
Check(supplierClass.group == "expense" && supplierClass.label == "Inventory",
|
|
"bunq: iban matching picks the supplier's category");
|
|
Financials twoCats;
|
|
Server::ApplyMutation(twoCats, billClass, bill); // -1200 out → +1200 Hosting
|
|
Server::ApplyMutation(twoCats, supplierClass, supplier); // -5000 out → +5000 Inventory
|
|
Check(twoCats.expenses.size() == 2
|
|
&& twoCats.expenses[0].label == "Hosting"
|
|
&& twoCats.expenses[0].totalMinor == 1200
|
|
&& twoCats.expenses[1].label == "Inventory"
|
|
&& twoCats.expenses[1].totalMinor == 5000,
|
|
"bunq: distinct labels become distinct rows, in first-seen order");
|
|
Check(twoCats.ExpensesMinor() == 6200, "bunq: the expense total is the sum of its rows");
|
|
}
|
|
|
|
// ── the callback gate ─────────────────────────────────────────────────
|
|
//
|
|
// The one endpoint that writes public money figures, and the only thing
|
|
// standing in front of it. Driven through ConfigureFinancials because that is
|
|
// how the real server reaches it; no key material and no network are needed
|
|
// to pin the parts that matter.
|
|
void CallbackGate() {
|
|
// Unconfigured: the path is a plain 404 and nothing authorises. An
|
|
// endpoint that is off should not announce itself by answering
|
|
// differently to a well-formed guess than to an empty one.
|
|
Server::ConfigureFinancials(Server::FinancialsConfig{});
|
|
Check(!Server::BunqCallbackConfigured(),
|
|
"callback: with no secret the endpoint does not exist");
|
|
Check(!Server::BunqCallbackAuthorised("", "{}", ""),
|
|
"callback: an empty secret authorises nothing while unconfigured");
|
|
Check(!Server::BunqCallbackAuthorised("s3cret-not-real", "{}", ""),
|
|
"callback: even a well-formed secret is refused while unconfigured");
|
|
|
|
Server::FinancialsConfig cfg;
|
|
cfg.callbackSecret = "s3cret-not-real"; // never a live one: the real
|
|
// secret only ever comes from
|
|
// the environment on the box
|
|
Server::ConfigureFinancials(cfg);
|
|
Check(Server::BunqCallbackAuthorised("s3cret-not-real", "{}", ""),
|
|
"callback: the exact secret is authorised");
|
|
// SecretEqual folds a length mismatch into the same accumulator as the
|
|
// byte differences, so neither a prefix nor an extension can return early
|
|
// — a plain == would leak the secret one byte at a time through timing,
|
|
// and the secret sits in the URL where it can be probed a request at a
|
|
// time.
|
|
Check(!Server::BunqCallbackAuthorised("s3cret-not-rea", "{}", ""),
|
|
"callback: a prefix of the secret is refused");
|
|
Check(!Server::BunqCallbackAuthorised("s3cret-not-realX", "{}", ""),
|
|
"callback: an extension of the secret is refused");
|
|
Check(!Server::BunqCallbackAuthorised("", "{}", ""),
|
|
"callback: an empty secret never matches a configured one");
|
|
// A secret with nowhere to write the aggregates is still no endpoint:
|
|
// this is what keeps the path a 404 on a box that has the env var but
|
|
// not the storage.
|
|
Check(!Server::BunqCallbackConfigured(),
|
|
"callback: a secret without an aggregates path leaves the endpoint off");
|
|
|
|
cfg.publicPath = "/nonexistent-catcrafts/financials.json";
|
|
Server::ConfigureFinancials(cfg);
|
|
Check(Server::BunqCallbackConfigured(),
|
|
"callback: secret plus aggregates path is what turns the endpoint on");
|
|
|
|
// Turning signature checking ON must never become a no-op. With a key
|
|
// path that cannot be read there is no way to verify anything, so the
|
|
// CORRECT secret now fails too — closed, not open.
|
|
cfg.publicKeyPem = "/nonexistent-catcrafts/bunq-public-key.pem";
|
|
Server::ConfigureFinancials(cfg);
|
|
Check(!Server::BunqCallbackAuthorised("s3cret-not-real", "{}", "YWJj"),
|
|
"callback: signature checking with an unreadable key fails closed");
|
|
|
|
Server::ConfigureFinancials(Server::FinancialsConfig{}); // leave no global behind
|
|
}
|
|
|
|
} // namespace
|
|
|
|
int main() {
|
|
FinancialsPage();
|
|
BunqIngest();
|
|
CallbackGate();
|
|
|
|
if (failures != 0) {
|
|
std::println(std::cerr, "{} check(s) failed", failures);
|
|
return 1;
|
|
}
|
|
return 0;
|
|
}
|