Files
rippled/src/tests/libxrpl/server/NodeIdentity.cpp
Pratik Mankawde e55f48caf8 test(server): cover every resolveNodeIdentity() decision branch
Lift the seed parsing and the stored-vs-mint choice into two libxrpl
helpers, parseNodeIdentitySeed() and selectNodeIdentity(), so xrpl_tests
can drive each branch without an xrpld Config. resolveNodeIdentity() now
marshals Config and the cmdline into them; behaviour is unchanged.

Also pin that storeNodeIdentity() appends (row count, not SQLite row
order), fix the test header that described getNodeIdentity()'s property
as the store's, and route NullTelemetry::getMeter() through noopMeter().

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 23:31:27 +01:00

362 lines
13 KiB
C++

/**
* @file NodeIdentity.cpp
* GTest unit tests for the wallet's node-identity helpers and the pure
* decision logic behind resolveNodeIdentity().
*
* Two groups of functions share the NodeIdentity table:
* - readNodeIdentity() / storeNodeIdentity() / clearNodeIdentity() are the
* wallet-layer primitives xrpld composes at startup.
* - getNodeIdentity(session&) is the read-or-mint helper. It persists a
* freshly minted pair, so a second call after a mint returns the same
* key: that is the property that keeps a node's identity stable across
* restarts.
*
* parseNodeIdentitySeed() and selectNodeIdentity() are the libxrpl-level
* decision helpers that xrpld's resolveNodeIdentity() marshals its inputs
* into. Every outcome branch of resolveNodeIdentity() reduces to one of
* these two, so testing them here covers the decision tree without an
* xrpld Config.
*
* Each database-backed test gets its own file in a temporary directory, so
* nothing here depends on order or on the developer's data directory.
*/
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/protocol/KeyType.h>
#include <xrpl/protocol/PublicKey.h>
#include <xrpl/protocol/SecretKey.h>
#include <xrpl/protocol/Seed.h>
#include <xrpl/protocol/tokens.h>
#include <xrpl/rdb/DatabaseCon.h>
#include <xrpl/server/Wallet.h>
#include <gtest/gtest.h>
#include <soci/into.h>
#include <soci/session.h>
#include <filesystem>
#include <functional>
#include <memory>
#include <optional>
#include <stdexcept>
#include <string>
#include <system_error>
#include <utility>
using namespace xrpl;
namespace {
/**
* A wallet database in its own temporary directory, removed on destruction.
*
* `makeTestWalletDB()` creates the schema, so every fixture starts with an
* empty `NodeIdentity` table.
*/
class TempWalletDb
{
public:
explicit TempWalletDb(std::string const& name)
: dir_(std::filesystem::temp_directory_path() / ("xrpl-node-identity-" + name))
{
std::filesystem::remove_all(dir_);
std::filesystem::create_directories(dir_);
DatabaseCon::Setup setup;
setup.dataDir = dir_;
db_ = makeTestWalletDB(setup, "wallet.db", beast::Journal{beast::Journal::getNullSink()});
}
~TempWalletDb()
{
db_.reset();
std::error_code ec;
std::filesystem::remove_all(dir_, ec);
}
TempWalletDb(TempWalletDb const&) = delete;
TempWalletDb&
operator=(TempWalletDb const&) = delete;
[[nodiscard]] DatabaseCon&
operator*() const noexcept
{
return *db_;
}
private:
std::filesystem::path dir_;
std::unique_ptr<DatabaseCon> db_;
};
} // namespace
TEST(WalletNodeIdentity, store_then_read_returns_the_same_pair)
{
// The store step exists so a key minted before the Application is built
// can be persisted afterwards. Reading it back must give the same pair, or
// the two halves of one run report two identities.
TempWalletDb const wallet("store-then-read");
auto const minted = randomKeyPair(KeyType::Secp256k1);
{
auto db = (*wallet).checkoutDb();
ASSERT_FALSE(readNodeIdentity(*db).has_value()) << "a fresh wallet must hold no identity";
storeNodeIdentity(*db, minted);
}
auto db = (*wallet).checkoutDb();
auto const stored = readNodeIdentity(*db);
ASSERT_TRUE(stored.has_value());
EXPECT_EQ(stored->first, minted.first);
EXPECT_EQ(stored->second, minted.second);
}
TEST(WalletNodeIdentity, store_appends_rather_than_replacing)
{
// Catches storeNodeIdentity being changed into an UPSERT: the wallet
// helper deliberately inserts without clearing, and the caller
// (getNodeIdentity(session&) or resolveNodeIdentity+setup) is what makes
// sure the table is empty first. Two stores must leave two rows.
TempWalletDb const wallet("store-appends");
auto const first = randomKeyPair(KeyType::Secp256k1);
auto const second = randomKeyPair(KeyType::Secp256k1);
ASSERT_NE(first.first, second.first)
<< "the two pairs must differ for this test to mean anything";
auto db = (*wallet).checkoutDb();
storeNodeIdentity(*db, first);
storeNodeIdentity(*db, second);
int rowCount = 0;
*db << "SELECT COUNT(*) FROM NodeIdentity;", soci::into(rowCount);
EXPECT_EQ(rowCount, 2) << "storeNodeIdentity must not clear the table";
// The read has no ORDER BY, so pin only that ONE of the two stored keys
// comes back -- not which one.
auto const stored = readNodeIdentity(*db);
ASSERT_TRUE(stored.has_value());
EXPECT_TRUE(stored->first == first.first || stored->first == second.first)
<< "readNodeIdentity must return one of the two stored pairs";
}
TEST(WalletNodeIdentity, clear_then_store_installs_the_new_pair)
{
// --newnodeid clears the row and then persists the freshly minted pair.
// Both steps are needed: clearing alone would leave the node with no
// stored identity at all.
TempWalletDb const wallet("clear-then-store");
auto db = (*wallet).checkoutDb();
auto const first = getNodeIdentity(*db);
auto const replacement = randomKeyPair(KeyType::Secp256k1);
ASSERT_NE(first.first, replacement.first);
clearNodeIdentity(*db);
EXPECT_FALSE(readNodeIdentity(*db).has_value()) << "clear must leave the table empty";
storeNodeIdentity(*db, replacement);
auto const stored = readNodeIdentity(*db);
ASSERT_TRUE(stored.has_value());
EXPECT_EQ(stored->first, replacement.first);
EXPECT_EQ(stored->second, replacement.second);
}
TEST(WalletNodeIdentity, get_mints_and_persists_when_the_table_is_empty)
{
// The mint path must persist, not just return: a second call has to give
// the same key. This is the property --newnodeid relies on to be
// meaningful, and the one a caller that only reads would break.
TempWalletDb const wallet("mint-and-persist");
auto db = (*wallet).checkoutDb();
auto const minted = getNodeIdentity(*db);
auto const stored = readNodeIdentity(*db);
ASSERT_TRUE(stored.has_value()) << "getNodeIdentity() must persist what it mints";
EXPECT_EQ(stored->first, minted.first);
EXPECT_EQ(getNodeIdentity(*db).first, minted.first);
}
// -----------------------------------------------------------------------------
// parseNodeIdentitySeed()
//
// The seed-parsing half of resolveNodeIdentity(). Each test drives one branch
// of the review's outcome list: cmdline valid, cmdline malformed, config valid,
// config malformed, neither, and cmdline-wins-over-config.
// -----------------------------------------------------------------------------
namespace {
// A base58 seed known to parse (from the "masterpassphrase" node in
// src/test/protocol/Seed_test.cpp).
constexpr auto kValidSeed = "snoPBrXtMeMyMHUVTgbuqAfg1SUTb";
// Public key derived from kValidSeed (secp256k1), same source.
constexpr auto kValidSeedPublic = "n94a1u4jAz288pZLtw6yFWVbi89YamiC6JBXPVUj5zmExe5fTVg9";
// A second base58 seed to prove cmdline-wins-over-config.
constexpr auto kOtherSeed = "snMKnVku798EnBwUfxeSD8953sLYA";
} // namespace
TEST(ParseNodeIdentitySeed, both_absent_returns_nullopt)
{
// Catches replacing the fallthrough with a throw, or making it mint a
// random seed. resolveNodeIdentity() then falls through to the wallet.
EXPECT_FALSE(parseNodeIdentitySeed(std::nullopt, std::nullopt).has_value());
}
TEST(ParseNodeIdentitySeed, valid_cmdline_returns_that_seed)
{
// Catches swapping parseGenericSeed to always return nullopt, or reading
// configSeed instead of cmdlineSeed. Pin the concrete public key derived
// from the seed so the returned optional cannot silently be a different
// valid seed.
auto const seed = parseNodeIdentitySeed(std::string{kValidSeed}, std::nullopt);
ASSERT_TRUE(seed.has_value());
auto const sk = generateSecretKey(KeyType::Secp256k1, *seed);
auto const pk = derivePublicKey(KeyType::Secp256k1, sk);
EXPECT_EQ(toBase58(TokenType::NodePublic, pk), std::string{kValidSeedPublic});
}
TEST(ParseNodeIdentitySeed, valid_config_returns_that_seed)
{
// Catches ignoring the config branch. Same public-key pin as above so the
// result cannot silently drift to another seed.
auto const seed = parseNodeIdentitySeed(std::nullopt, std::string{kValidSeed});
ASSERT_TRUE(seed.has_value());
auto const sk = generateSecretKey(KeyType::Secp256k1, *seed);
auto const pk = derivePublicKey(KeyType::Secp256k1, sk);
EXPECT_EQ(toBase58(TokenType::NodePublic, pk), std::string{kValidSeedPublic});
}
TEST(ParseNodeIdentitySeed, malformed_cmdline_throws)
{
// Catches removing the throw. An empty string flunks parseGenericSeed(
// rfc1751=false) because the first check inside is str.empty(). A base58
// public key would too, but empty is the shorter probe.
EXPECT_THROW(
static_cast<void>(parseNodeIdentitySeed(std::string{}, std::nullopt)), std::runtime_error);
}
TEST(ParseNodeIdentitySeed, malformed_config_throws)
{
// Catches removing the throw. "garbage" is neither valid base58 nor a
// valid seed encoding, so parseBase58<Seed> returns nullopt and the
// config branch throws.
EXPECT_THROW(
static_cast<void>(parseNodeIdentitySeed(std::nullopt, std::string{"garbage"})),
std::runtime_error);
}
TEST(ParseNodeIdentitySeed, cmdline_wins_over_config)
{
// Catches swapping the two if-branches. Passing DIFFERENT valid seeds on
// each input and asserting the returned seed derives to kValidSeed's
// public key proves which one won.
auto const seed = parseNodeIdentitySeed(std::string{kValidSeed}, std::string{kOtherSeed});
ASSERT_TRUE(seed.has_value());
auto const sk = generateSecretKey(KeyType::Secp256k1, *seed);
auto const pk = derivePublicKey(KeyType::Secp256k1, sk);
EXPECT_EQ(toBase58(TokenType::NodePublic, pk), std::string{kValidSeedPublic});
}
// -----------------------------------------------------------------------------
// selectNodeIdentity()
//
// The decision half of resolveNodeIdentity(). Every remaining branch from the
// review's outcome list reduces to one of these four cases, because
// storedIdentity() catches its own exceptions and returns std::nullopt for
// every failure mode (standalone non-Load, wallet absent, invalid row, read
// throws) -- see storedIdentity() in NodeIdentity.cpp.
// -----------------------------------------------------------------------------
namespace {
// Reader that records whether it was called. resolveNodeIdentity()'s reader
// is a filesystem-touching lambda, so pinning "was it consulted" catches the
// mutations that flip which cases open the wallet.
struct TrackingReader
{
bool called{false};
std::optional<std::pair<PublicKey, SecretKey>> value;
std::function<std::optional<std::pair<PublicKey, SecretKey>>()>
fn()
{
return [this] {
called = true;
return value;
};
}
};
} // namespace
TEST(SelectNodeIdentity, seed_wins_and_reader_not_consulted)
{
// Catches removing the seed branch (or checking newNodeId first). The
// returned pair must be keysFromSeed(kValidSeed); if the seed branch is
// gone the reader gets called and its recorded pair or a fresh mint
// comes back instead.
TrackingReader reader;
reader.value = randomKeyPair(KeyType::Secp256k1);
auto const seed = parseBase58<Seed>(std::string{kValidSeed});
ASSERT_TRUE(seed.has_value());
auto const result = selectNodeIdentity(seed, /*newNodeId=*/false, reader.fn());
EXPECT_FALSE(reader.called) << "the reader must not run when a seed is configured";
EXPECT_EQ(toBase58(TokenType::NodePublic, result.first), std::string{kValidSeedPublic});
}
TEST(SelectNodeIdentity, newnodeid_mints_fresh_and_skips_reader)
{
// Catches removing the `!newNodeId` guard. With a stored pair available,
// the mint path must still run and the stored pair must not come back.
TrackingReader reader;
reader.value = randomKeyPair(KeyType::Secp256k1);
auto const storedPair = *reader.value;
auto const result = selectNodeIdentity(std::nullopt, /*newNodeId=*/true, reader.fn());
EXPECT_FALSE(reader.called) << "--newnodeid must not open the wallet";
EXPECT_NE(result.first, storedPair.first) << "the stored pair must be discarded";
}
TEST(SelectNodeIdentity, returns_stored_when_reader_has_one)
{
// Catches replacing the stored-return with a mint. Also catches the
// reader being called but its result discarded.
TrackingReader reader;
reader.value = randomKeyPair(KeyType::Secp256k1);
auto const storedPair = *reader.value;
auto const result = selectNodeIdentity(std::nullopt, /*newNodeId=*/false, reader.fn());
EXPECT_TRUE(reader.called);
EXPECT_EQ(result.first, storedPair.first);
EXPECT_EQ(result.second, storedPair.second);
}
TEST(SelectNodeIdentity, mints_when_nothing_stored)
{
// Catches removing the mint fallback. With the reader returning
// nullopt, selectNodeIdentity must still produce a keypair, and it must
// differ from anything it could have accidentally reused.
TrackingReader reader; // value stays std::nullopt.
auto const result = selectNodeIdentity(std::nullopt, /*newNodeId=*/false, reader.fn());
EXPECT_TRUE(reader.called);
// The mint path is randomKeyPair(), so the two calls must yield distinct
// keys. Same probe the wallet-side tests use.
auto const another = randomKeyPair(KeyType::Secp256k1);
EXPECT_NE(result.first, another.first);
}