mirror of
https://github.com/logos-co/logos-verified-proxy-module.git
synced 2026-08-27 13:01:09 +00:00
188 lines
7.5 KiB
C++
188 lines
7.5 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 <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");
|
||
|
|
}
|