From 1b4fede15b7525a88fb7f357a257ceeb7a4825a7 Mon Sep 17 00:00:00 2001 From: Sergey Kuznetsov Date: Wed, 12 Aug 2026 16:01:29 +0100 Subject: [PATCH] Add unit tests for host function --- .../libxrpl/tx/wasm/HostContextFixture.cpp | 52 ++++++++ .../libxrpl/tx/wasm/HostContextFixture.h | 56 ++++++++ src/tests/libxrpl/tx/wasm/MockHostFunctions.h | 15 ++- .../libxrpl/tx/wasm/host_context/TxField.cpp | 126 ++++++++++++++++++ 4 files changed, 245 insertions(+), 4 deletions(-) create mode 100644 src/tests/libxrpl/tx/wasm/HostContextFixture.cpp create mode 100644 src/tests/libxrpl/tx/wasm/HostContextFixture.h create mode 100644 src/tests/libxrpl/tx/wasm/host_context/TxField.cpp diff --git a/src/tests/libxrpl/tx/wasm/HostContextFixture.cpp b/src/tests/libxrpl/tx/wasm/HostContextFixture.cpp new file mode 100644 index 0000000000..341218a7d5 --- /dev/null +++ b/src/tests/libxrpl/tx/wasm/HostContextFixture.cpp @@ -0,0 +1,52 @@ +#include + +#include +#include +#include +#include +#include + +namespace xrpl::test { + +rust::Slice +HostContextTest::bytesOf(Bytes const& bytes) +{ + return rust::Slice{bytes.data(), bytes.size()}; +} + +HostContextTest::OutRegion::OutRegion(std::size_t capacity) : bytes(capacity, kSentinel) +{ +} + +rust::Slice +HostContextTest::OutRegion::slice() +{ + return rust::Slice{bytes.data(), bytes.size()}; +} + +bool +HostContextTest::OutRegion::wasWritten() const +{ + return std::ranges::any_of(bytes, [](std::uint8_t b) { return b != kSentinel; }); +} + +bool +HostContextTest::OutRegion::holds(rust::Slice expected) const +{ + if (expected.size() > bytes.size()) + { + return false; + } + + auto want = std::vector(bytes.size(), kSentinel); + std::ranges::copy(expected, want.begin()); + return bytes == want; +} + +std::string +HostContextTest::logged() const +{ + return sink.messages(); +} + +} // namespace xrpl::test diff --git a/src/tests/libxrpl/tx/wasm/HostContextFixture.h b/src/tests/libxrpl/tx/wasm/HostContextFixture.h new file mode 100644 index 0000000000..6499cccf59 --- /dev/null +++ b/src/tests/libxrpl/tx/wasm/HostContextFixture.h @@ -0,0 +1,56 @@ +#pragma once + +#include +#include +#include + +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace xrpl::test { + +// Base for the tests that construct `HostContext` directly, rather than reaching it through +// an assembled module. +struct HostContextTest : testing::Test +{ + static rust::Slice + bytesOf(Bytes const& bytes); + + // Filled with a sentinel rather than left at zero: an answer can itself be all zero, so + // only a byte no answer produces tells "wrote nothing" apart from "wrote zeros". + struct OutRegion + { + static constexpr std::uint8_t kSentinel = 0xcd; + + std::vector bytes; + + explicit OutRegion(std::size_t capacity); + + rust::Slice + slice(); + + [[nodiscard]] bool + wasWritten() const; + + // Means "this value and nothing past it". + [[nodiscard]] bool + holds(rust::Slice expected) const; + }; + + CaptureSink sink{beast::Severity::Warning}; + testing::StrictMock host{beast::Journal{sink}}; + HostContext hostContext{host}; + + [[nodiscard]] std::string + logged() const; +}; + +} // namespace xrpl::test diff --git a/src/tests/libxrpl/tx/wasm/MockHostFunctions.h b/src/tests/libxrpl/tx/wasm/MockHostFunctions.h index d75291cc9a..096b627d66 100644 --- a/src/tests/libxrpl/tx/wasm/MockHostFunctions.h +++ b/src/tests/libxrpl/tx/wasm/MockHostFunctions.h @@ -16,10 +16,11 @@ namespace xrpl::test { // A mock of the host the wasm engine calls back into. // -// Only the methods the ABI currently declares are mocked, and that is deliberate: the ~60 -// others keep `HostFunctions`' own `std::unexpected(Unimplemented)`, so a contract reaching -// for something the ABI has not declared yet fails the way production would. Add a -// `MOCK_METHOD` here when the matching entry is added to `host_functions!`. +// Only a few of `HostFunctions`' methods are mocked here. That is the mock lagging the ABI, +// not the ABI lacking coverage: `crates/xrpl-host-functions/src/lib.rs` already declares all +// 61 entries. Each one not yet mocked keeps `HostFunctions`' own +// `std::unexpected(Unimplemented)`, so a contract reaching for it fails the way production +// would. Add a `MOCK_METHOD` here as tests for that method are written. struct MockHostFunctions : HostFunctions { explicit MockHostFunctions(beast::Journal journal) : HostFunctions(journal) @@ -46,6 +47,12 @@ struct MockHostFunctions : HostFunctions (Slice const& data), (const, override)); + MOCK_METHOD( + (std::expected), + getTxField, + (SField const& fname), + (const, override)); + // Takes the rendered text, not the guest's buffer: rendering is `HostContext`'s, so what // a test asserts here is the log line a node would write. MOCK_METHOD( diff --git a/src/tests/libxrpl/tx/wasm/host_context/TxField.cpp b/src/tests/libxrpl/tx/wasm/host_context/TxField.cpp new file mode 100644 index 0000000000..24b73bb63c --- /dev/null +++ b/src/tests/libxrpl/tx/wasm/host_context/TxField.cpp @@ -0,0 +1,126 @@ +#include +#include +#include + +#include +#include +#include + +#include +#include +#include + +namespace xrpl::test { + +// The engine's own rules - buffer-fit, the field cap, guest memory - are tested on the Rust +// side, not here. +struct TxFieldCall : HostContextTest +{ + std::int32_t fieldCode = sfBalance.getCode(); +}; + +TEST_F(TxFieldCall, FieldCodeBecomesSFieldHostIsAskedFor) +{ + Bytes const value{1, 2, 3}; + EXPECT_CALL(host, getTxField(testing::Ref(sfBalance))).WillOnce(testing::Return(value)); + + OutRegion out{32}; + EXPECT_EQ( + hostContext.getTxField(fieldCode, out.slice()), static_cast(value.size())); + EXPECT_TRUE(out.holds(bytesOf(value))); +} + +TEST_F(TxFieldCall, HostErrorBecomesContractReturnValue) +{ + EXPECT_CALL(host, getTxField(testing::Ref(sfBalance))) + .WillOnce(testing::Return(std::unexpected(HostFunctionError::FieldNotFound))); + + OutRegion out{32}; + EXPECT_EQ( + hostContext.getTxField(fieldCode, out.slice()), + hfErrorToInt(HostFunctionError::FieldNotFound)); + EXPECT_FALSE(out.wasWritten()); +} + +TEST_F(TxFieldCall, UnknownFieldCodeIsRefusedWithoutAskingHost) +{ + fieldCode = 0x7fff'0000; // a code nothing is registered under + EXPECT_CALL(host, getTxField).Times(0); + + OutRegion out{32}; + EXPECT_EQ( + hostContext.getTxField(fieldCode, out.slice()), + hfErrorToInt(HostFunctionError::InvalidField)); +} + +TEST_F(TxFieldCall, HostExceptionBecomesInternalFatalAndIsLogged) +{ + EXPECT_CALL(host, getTxField(testing::Ref(sfBalance))) + .WillOnce(testing::Throw(std::runtime_error{"balance field came apart"})); + + OutRegion out{32}; + EXPECT_EQ( + hostContext.getTxField(fieldCode, out.slice()), + hfErrorToInt(HostFunctionError::InternalFatal)); + EXPECT_THAT(logged(), testing::HasSubstr("balance field came apart")); + EXPECT_THAT(logged(), testing::HasSubstr("getTxField")); +} + +// `guarded`'s `catch (...)` arm, for a thrown value that is not a `std::exception`. +TEST_F(TxFieldCall, NonStandardThrowBecomesInternalFatalAndIsLogged) +{ + EXPECT_CALL(host, getTxField(testing::Ref(sfBalance))).WillOnce(testing::Throw(42)); + + OutRegion out{32}; + EXPECT_EQ( + hostContext.getTxField(fieldCode, out.slice()), + hfErrorToInt(HostFunctionError::InternalFatal)); + EXPECT_THAT(logged(), testing::HasSubstr("getTxField")); +} + +// The out-region contract: write only if the whole value fits, and return the true length +// either way. +TEST_F(TxFieldCall, ShortOutRegionWritesNothingAndReturnsTrueLength) +{ + Bytes const value{1, 2, 3}; + EXPECT_CALL(host, getTxField(testing::Ref(sfBalance))).WillOnce(testing::Return(value)); + + OutRegion out{value.size() - 1}; + EXPECT_EQ( + hostContext.getTxField(fieldCode, out.slice()), static_cast(value.size())); + EXPECT_FALSE(out.wasWritten()); +} + +TEST_F(TxFieldCall, OutRegionOfExactSizeIsWritten) +{ + Bytes const value{1, 2, 3}; + EXPECT_CALL(host, getTxField(testing::Ref(sfBalance))).WillOnce(testing::Return(value)); + + OutRegion out{value.size()}; + EXPECT_EQ( + hostContext.getTxField(fieldCode, out.slice()), static_cast(value.size())); + EXPECT_TRUE(out.holds(bytesOf(value))); +} + +// `kMaxWasmDataLength` is the engine's cap, not `HostContext`'s: a length past it crosses +// unchanged here, where the sibling engine test sees `DataFieldTooLarge` instead. +TEST_F(TxFieldCall, LengthPastProtocolCapCrossesUnchanged) +{ + Bytes const value(kMaxWasmDataLength + 1, 0xab); + EXPECT_CALL(host, getTxField(testing::Ref(sfBalance))).WillOnce(testing::Return(value)); + + OutRegion out{value.size()}; + EXPECT_EQ( + hostContext.getTxField(fieldCode, out.slice()), static_cast(value.size())); +} + +TEST_F(TxFieldCall, EmptyResultAnswersZeroAndWritesNothing) +{ + EXPECT_CALL(host, getTxField(testing::Ref(sfBalance))).WillOnce(testing::Return(Bytes{})); + + OutRegion out{32}; + EXPECT_EQ(hostContext.getTxField(fieldCode, out.slice()), 0); + EXPECT_FALSE(out.wasWritten()); +} + +} // namespace xrpl::test