Add unit tests for host function

This commit is contained in:
Sergey Kuznetsov
2026-08-12 16:01:29 +01:00
parent 2605b4a78b
commit 1b4fede15b
4 changed files with 245 additions and 4 deletions

View File

@@ -0,0 +1,52 @@
#include <tx/wasm/HostContextFixture.h>
#include <algorithm>
#include <cstddef>
#include <cstdint>
#include <string>
#include <vector>
namespace xrpl::test {
rust::Slice<std::uint8_t const>
HostContextTest::bytesOf(Bytes const& bytes)
{
return rust::Slice<std::uint8_t const>{bytes.data(), bytes.size()};
}
HostContextTest::OutRegion::OutRegion(std::size_t capacity) : bytes(capacity, kSentinel)
{
}
rust::Slice<std::uint8_t>
HostContextTest::OutRegion::slice()
{
return rust::Slice<std::uint8_t>{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<std::uint8_t const> expected) const
{
if (expected.size() > bytes.size())
{
return false;
}
auto want = std::vector<std::uint8_t>(bytes.size(), kSentinel);
std::ranges::copy(expected, want.begin());
return bytes == want;
}
std::string
HostContextTest::logged() const
{
return sink.messages();
}
} // namespace xrpl::test

View File

@@ -0,0 +1,56 @@
#pragma once
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/tx/wasm/HostContext.h>
#include <xrpl/tx/wasm/WasmCommon.h>
#include <gmock/gmock.h>
#include <gtest/gtest.h>
#include <helpers/CaptureSink.h>
#include <rust/cxx.h>
#include <tx/wasm/MockHostFunctions.h>
#include <cstddef>
#include <cstdint>
#include <string>
#include <vector>
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<std::uint8_t const>
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<std::uint8_t> bytes;
explicit OutRegion(std::size_t capacity);
rust::Slice<std::uint8_t>
slice();
[[nodiscard]] bool
wasWritten() const;
// Means "this value and nothing past it".
[[nodiscard]] bool
holds(rust::Slice<std::uint8_t const> expected) const;
};
CaptureSink sink{beast::Severity::Warning};
testing::StrictMock<MockHostFunctions> host{beast::Journal{sink}};
HostContext hostContext{host};
[[nodiscard]] std::string
logged() const;
};
} // namespace xrpl::test

View File

@@ -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<Bytes, HostFunctionError>),
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(

View File

@@ -0,0 +1,126 @@
#include <xrpl/protocol/Protocol.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/tx/wasm/WasmCommon.h>
#include <gmock/gmock.h>
#include <gtest/gtest.h>
#include <tx/wasm/HostContextFixture.h>
#include <cstdint>
#include <expected>
#include <stdexcept>
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<std::int32_t>(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<std::int32_t>(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<std::int32_t>(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<std::int32_t>(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