Files
logos-verified-proxy-module/tests/test_config_validation.cpp
T
Dario Gabriel LipicarandClaude Opus 5 3f22acec91 feat: one network table, with live-verified default endpoints per chain
The supported-network set was written out in three places — the configure()
whitelist, expectedChainId(), and the panel's hardcoded dropdown model — and
adding per-chain defaults would have made four. They are now one table, exposed
as supportedNetworks() so a UI builds its selector from the module's own
whitelist. That is a safety property, not tidiness: `network` is one of two
config fields whose value reaches a quit() inside Nim when upstream does not
recognise it, so a UI list that drifts from the whitelist kills the host.

The defaults are live-verified, not sourced from documentation. A beacon URL is
only listed if /eth/v1/beacon/light_client/bootstrap/<root> answered 200, and an
execution URL only if eth_getProof returned a result. Both filters matter:
several hosts serve the standard beacon API but 404 the light_client namespace
(Checkpointz instances especially, which answer /eth/v1/node/version and look
healthy), and several long-published RPC URLs are now dead, key-gated or
intermittent.

mainnet and hoodi take drpc for execution because it answered eth_getProof deep
in history where the pruning free tiers refuse anything past ~head-1024. That
distinction is load-bearing here rather than cosmetic: the light client verifies
against its FINALIZED header, which lags the head, so a pruning provider fails
proofs for precisely the blocks this module asks about — and it surfaces as
"distance to target block exceeds maximum proof window" long after start()
reported success. Sepolia stays on publicnode because dRPC gates that chain
behind a paid plan.

Six tests pin the table's invariants: it covers exactly the three networks
upstream compiles in, every entry is accepted by configure(), every chain id is
non-zero (0 is the sentinel that would silently disable the post-start chain
check), lookup rejects a plausible typo, non-empty defaults are well formed and
all-or-nothing, and a profile's defaults are accepted as a real config.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 17:11:32 -03:00

270 lines
11 KiB
C++

// Configuration validation.
//
// These are the cheapest and highest-value tests in the suite: pure C++, no
// mock, no threads — and two of them guard a path that would otherwise take
// down the whole HOST process, because `startVerifProxy` reaches a Nim `quit()`
// for an unrecognised network or log level.
#include <set>
#include <string>
#include <logos_test.h>
#include <nlohmann/json.hpp>
#include "proxy_config.h"
using json = nlohmann::json;
namespace {
json baseConfig() {
return json{
{ "network", "sepolia" },
{ "trustedBlockRoot", "0x" + std::string(64, 'a') },
{ "executionApiUrls", json::array({ "wss://eth.example/v2/secret-key" }) },
{ "beaconApiUrls", json::array({ "https://beaconstate.info" }) },
};
}
bool accepts(const json& j, std::string& err) {
ProxyConfig c;
return ProxyConfig::fromJson(j, c, err);
}
json withField(const char* key, const json& value) {
json j = baseConfig();
j[key] = value;
return j;
}
} // namespace
LOGOS_TEST(config_accepts_a_minimal_valid_document) {
std::string err;
LOGOS_ASSERT_TRUE(accepts(baseConfig(), err));
LOGOS_ASSERT_TRUE(err.empty());
}
// --- the two host-killing fields -------------------------------------------
LOGOS_TEST(config_rejects_every_network_outside_the_whitelist) {
// Upstream's getMetadataForNetwork has only mainnet/hoodi/sepolia compiled
// in; anything else falls through to `fatal` + `quit 1`. "holesky" and
// "op-mainnet" are the realistic mistakes — both are real network names
// that simply are not valid for the LIBRARY's JSON config.
for (const char* bad : { "goerli", "holesky", "op-mainnet", "base-mainnet",
"Mainnet", "MAINNET", "" }) {
std::string err;
LOGOS_ASSERT_FALSE(accepts(withField("network", bad), err));
LOGOS_ASSERT_CONTAINS(err, "network");
}
for (const char* good : { "mainnet", "sepolia", "hoodi" }) {
std::string err;
LOGOS_ASSERT_TRUE(accepts(withField("network", good), err));
}
}
LOGOS_TEST(config_rejects_every_log_level_outside_the_whitelist) {
// Nim's updateLogLevel raises ValueError, and setupLogging turns that into
// `quit 1`. Note lowercase "info" is rejected: upstream is case-sensitive.
for (const char* bad : { "verbose", "info", "Silly", "" }) {
std::string err;
LOGOS_ASSERT_FALSE(accepts(withField("logLevel", bad), err));
LOGOS_ASSERT_CONTAINS(err, "logLevel");
}
for (const char* good : { "TRACE", "DEBUG", "INFO", "NOTICE",
"WARN", "ERROR", "FATAL", "NONE" }) {
std::string err;
LOGOS_ASSERT_TRUE(accepts(withField("logLevel", good), err));
}
}
// --- ordinary validation ----------------------------------------------------
LOGOS_TEST(config_requires_a_well_formed_trusted_block_root) {
std::string err;
json noRoot = baseConfig();
noRoot.erase("trustedBlockRoot");
LOGOS_ASSERT_FALSE(accepts(noRoot, err));
LOGOS_ASSERT_FALSE(accepts(withField("trustedBlockRoot", "0xdeadbeef"), err));
LOGOS_ASSERT_FALSE(accepts(withField("trustedBlockRoot", std::string(64, 'a')), err));
LOGOS_ASSERT_FALSE(accepts(withField("trustedBlockRoot", "0x" + std::string(64, 'z')), err));
LOGOS_ASSERT_FALSE(accepts(withField("trustedBlockRoot", 42), err));
}
LOGOS_TEST(config_requires_both_backend_url_lists) {
std::string err;
LOGOS_ASSERT_FALSE(accepts(withField("executionApiUrls", json::array()), err));
LOGOS_ASSERT_CONTAINS(err, "executionApiUrls");
LOGOS_ASSERT_FALSE(accepts(withField("beaconApiUrls", json::array()), err));
LOGOS_ASSERT_CONTAINS(err, "beaconApiUrls");
}
LOGOS_TEST(config_rejects_url_schemes_upstream_would_reject) {
std::string err;
for (const char* bad : { "ftp://x", "file:///etc/passwd", "eth.example", "" }) {
LOGOS_ASSERT_FALSE(accepts(withField("beaconApiUrls", json::array({ bad })), err));
}
for (const char* good : { "http://a", "https://a", "ws://a", "wss://a" }) {
LOGOS_ASSERT_TRUE(accepts(withField("beaconApiUrls", json::array({ good })), err));
}
}
LOGOS_TEST(config_rejects_a_comma_inside_a_single_url) {
// Upstream's format is one comma-separated string, so a comma in an entry
// would silently become two URLs after we join. Catch it while the caller
// can still see which entry is wrong.
std::string err;
LOGOS_ASSERT_FALSE(
accepts(withField("executionApiUrls", json::array({ "https://a,https://b" })), err));
LOGOS_ASSERT_CONTAINS(err, "comma");
}
LOGOS_TEST(config_accepts_upstreams_own_comma_separated_spelling) {
// A caller pasting the upstream shape should not be punished for it.
std::string err;
ProxyConfig c;
LOGOS_ASSERT_TRUE(ProxyConfig::fromJson(
withField("executionApiUrls", "https://a,https://b"), c, err));
LOGOS_ASSERT_EQ(c.executionApiUrls.size(), static_cast<size_t>(2));
}
LOGOS_TEST(config_rejects_nonsensical_module_knobs) {
std::string err;
LOGOS_ASSERT_FALSE(accepts(withField("callTimeoutMs", 0), err));
LOGOS_ASSERT_FALSE(accepts(withField("startTimeoutMs", -1), err));
LOGOS_ASSERT_FALSE(accepts(withField("maxInFlight", 0), err));
LOGOS_ASSERT_FALSE(accepts(withField("keepAlive", "sometimes"), err));
LOGOS_ASSERT_TRUE(accepts(withField("keepAlive", "continuous"), err));
LOGOS_ASSERT_TRUE(accepts(withField("keepAlive", "off"), err));
}
// --- translation to the upstream shape --------------------------------------
LOGOS_TEST(config_translates_url_arrays_to_upstreams_comma_separated_strings) {
ProxyConfig c;
std::string err;
json j = baseConfig();
j["executionApiUrls"] = json::array({ "https://a", "https://b" });
LOGOS_ASSERT_TRUE(ProxyConfig::fromJson(j, c, err));
const json up = json::parse(c.toUpstreamJson());
LOGOS_ASSERT_TRUE(up["executionApiUrls"].is_string());
LOGOS_ASSERT_EQ(up["executionApiUrls"].get<std::string>(), std::string("https://a,https://b"));
// Upstream's key is eth2Network, not `network`.
LOGOS_ASSERT_EQ(up["eth2Network"].get<std::string>(), std::string("sepolia"));
// Module-only knobs must NOT leak into the library's config.
LOGOS_ASSERT_FALSE(up.contains("callTimeoutMs"));
LOGOS_ASSERT_FALSE(up.contains("keepAlive"));
LOGOS_ASSERT_FALSE(up.contains("tuning"));
}
LOGOS_TEST(config_maps_each_network_to_its_chain_id) {
ProxyConfig c;
std::string err;
ProxyConfig::fromJson(withField("network", "mainnet"), c, err);
LOGOS_ASSERT_EQ(c.expectedChainId(), static_cast<int64_t>(1));
ProxyConfig::fromJson(withField("network", "sepolia"), c, err);
LOGOS_ASSERT_EQ(c.expectedChainId(), static_cast<int64_t>(11155111));
ProxyConfig::fromJson(withField("network", "hoodi"), c, err);
LOGOS_ASSERT_EQ(c.expectedChainId(), static_cast<int64_t>(560048));
}
LOGOS_TEST(config_redacts_provider_credentials) {
ProxyConfig c;
std::string err;
json j = baseConfig();
j["executionApiUrls"] = json::array({
"wss://eth-mainnet.g.alchemy.com/v2/SUPER-SECRET",
"https://user:password@node.example/rpc?apikey=SECRET",
});
LOGOS_ASSERT_TRUE(ProxyConfig::fromJson(j, c, err));
const std::string dumped = c.redacted().dump();
LOGOS_ASSERT_FALSE(dumped.find("SUPER-SECRET") != std::string::npos);
LOGOS_ASSERT_FALSE(dumped.find("password") != std::string::npos);
LOGOS_ASSERT_FALSE(dumped.find("apikey=SECRET") != std::string::npos);
// The host must survive, or the redaction is useless for diagnosis.
LOGOS_ASSERT_CONTAINS(dumped, "eth-mainnet.g.alchemy.com");
}
// ── the network profile table ───────────────────────────────────────────────
//
// One table now backs the whitelist, the chain ids and the UI's prefill
// defaults. These pin the invariants that keep those three in step, because a
// drift between them is not a cosmetic bug: an accepted network with no chain
// id silently disables the post-start chain check, and an unaccepted one
// reaches a Nim quit() that kills the host.
LOGOS_TEST(profiles_cover_exactly_the_networks_upstream_compiles_in) {
const auto& profiles = networkProfiles();
LOGOS_ASSERT_EQ(static_cast<int>(profiles.size()), 3);
std::set<std::string> names;
for (const auto& p : profiles) names.insert(p.name);
LOGOS_ASSERT_TRUE(names.count("mainnet") == 1);
LOGOS_ASSERT_TRUE(names.count("sepolia") == 1);
LOGOS_ASSERT_TRUE(names.count("hoodi") == 1);
}
LOGOS_TEST(every_profile_is_accepted_by_configure) {
// The whitelist derives from the table, so a network offered to a UI can
// never be one that configure() rejects — or worse, one it accepts and
// upstream quit()s on.
for (const auto& p : networkProfiles()) {
json c = baseConfig();
c["network"] = p.name;
ProxyConfig out;
std::string err;
LOGOS_ASSERT_TRUE(ProxyConfig::fromJson(c, out, err));
LOGOS_ASSERT_EQ(err, std::string(""));
LOGOS_ASSERT_EQ(out.expectedChainId(), p.chainId);
}
}
LOGOS_TEST(every_profile_has_a_real_chain_id) {
// 0 is the "unknown network" sentinel expectedChainId() returns, so a 0
// here would mean the post-start chain check compares against nothing.
for (const auto& p : networkProfiles())
LOGOS_ASSERT_GT(p.chainId, 0);
}
LOGOS_TEST(profile_lookup_rejects_an_unknown_network) {
LOGOS_ASSERT_TRUE(networkProfile("mainnet") != nullptr);
LOGOS_ASSERT_TRUE(networkProfile("holesky") == nullptr); // a plausible typo
LOGOS_ASSERT_TRUE(networkProfile("") == nullptr);
}
LOGOS_TEST(profile_default_urls_are_empty_or_well_formed) {
// A default is optional — empty means "no public endpoint qualifies" — but
// a NON-empty one is prefilled straight into a form and submitted, so it
// must survive the same validation any typed URL does.
for (const auto& p : networkProfiles()) {
for (const std::string& url : { p.beaconApiUrl, p.executionApiUrl }) {
if (url.empty()) continue;
LOGOS_ASSERT_TRUE(url.rfind("http://", 0) == 0 || url.rfind("https://", 0) == 0
|| url.rfind("ws://", 0) == 0 || url.rfind("wss://", 0) == 0);
}
// A default pair must be all-or-nothing: prefilling one field and
// leaving the other blank produces a form that looks ready and is not.
LOGOS_ASSERT_EQ(p.beaconApiUrl.empty(), p.executionApiUrl.empty());
}
}
LOGOS_TEST(a_profiles_defaults_are_accepted_as_a_real_config) {
// The end-to-end claim a UI relies on: prefill from a profile, submit, and
// configure() takes it.
for (const auto& p : networkProfiles()) {
if (p.beaconApiUrl.empty()) continue;
json c = baseConfig();
c["network"] = p.name;
c["beaconApiUrls"] = json::array({ p.beaconApiUrl });
c["executionApiUrls"] = json::array({ p.executionApiUrl });
ProxyConfig out;
std::string err;
LOGOS_ASSERT_TRUE(ProxyConfig::fromJson(c, out, err));
LOGOS_ASSERT_EQ(err, std::string(""));
}
}