test: cover audit fixes (atomic writes, opid routing, sqlite GC, lite tx)

- testAtomicFileWrite: Platform::writeFileAtomically creates dirs, overwrites,
  leaves no .tmp, and honors owner-only perms.
- failureByOpid assertion in the operation-status poll parser test.
- testTransactionHistoryCachePrunesOldWallets: a save under a new identity prunes
  the prior identity's snapshot.
- testLiteSendShowsRecipientFromOutgoing / testLitePartialRefreshKeepsPriorAddressBalances.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-07 14:18:34 -05:00
parent e00772db6e
commit e978db85ca

View File

@@ -20,6 +20,7 @@
#include "ui/windows/mining_tab_helpers.h" #include "ui/windows/mining_tab_helpers.h"
#include "util/amount_format.h" #include "util/amount_format.h"
#include "util/payment_uri.h" #include "util/payment_uri.h"
#include "util/platform.h"
#include "util/xmrig_updater.h" #include "util/xmrig_updater.h"
#include "util/lite_server_probe.h" #include "util/lite_server_probe.h"
#include "wallet/lite_connection_service.h" #include "wallet/lite_connection_service.h"
@@ -1872,6 +1873,9 @@ void testOperationStatusPollParsing()
EXPECT_EQ(parsed.successTxidsByOpid.at("op-success"), std::string("tx-success")); EXPECT_EQ(parsed.successTxidsByOpid.at("op-success"), std::string("tx-success"));
EXPECT_EQ(parsed.failureMessages.size(), static_cast<size_t>(1)); EXPECT_EQ(parsed.failureMessages.size(), static_cast<size_t>(1));
EXPECT_EQ(parsed.failureMessages[0], std::string("bad memo")); EXPECT_EQ(parsed.failureMessages[0], std::string("bad memo"));
// Per-opid failure map drives terminal-status routing to the originating send UI.
EXPECT_EQ(parsed.failureByOpid.size(), static_cast<size_t>(1));
EXPECT_EQ(parsed.failureByOpid.at("op-failed"), std::string("bad memo"));
EXPECT_EQ(parsed.staleOpids.size(), static_cast<size_t>(1)); EXPECT_EQ(parsed.staleOpids.size(), static_cast<size_t>(1));
EXPECT_EQ(parsed.staleOpids[0], std::string("op-stale")); EXPECT_EQ(parsed.staleOpids[0], std::string("op-stale"));
@@ -2640,6 +2644,38 @@ void testTransactionHistoryCache()
fs::remove_all(dir); fs::remove_all(dir);
} }
// GC: generating a new address changes the wallet-identity hash; the prior hash's snapshot
// + salt must not accumulate forever. A save under a new identity prunes the old rows.
void testTransactionHistoryCachePrunesOldWallets()
{
using dragonx::TransactionInfo;
using dragonx::data::TransactionHistoryCache;
fs::path dir = makeTempDir();
fs::path dbPath = dir / "transaction_history.sqlite";
const std::string idA = "mainnet|R-a|zs-a";
const std::string idB = "mainnet|R-a|zs-a|zs-newly-generated"; // new address -> new identity
const std::string pass = "correct horse battery staple";
std::vector<TransactionInfo> txs;
txs.push_back(TransactionInfo{"tx1", "mined", 1.0, 1700000000, 10, "R-a", "", ""});
TransactionHistoryCache cache(dbPath.string());
EXPECT_TRUE(cache.unlockWithPassphrase(idA, pass));
EXPECT_TRUE(cache.replace(idA, 100, "tip-a", txs, 1700000000));
EXPECT_EQ(cache.snapshotCount(), 1);
EXPECT_TRUE(cache.unlockWithPassphrase(idB, pass));
EXPECT_TRUE(cache.replace(idB, 100, "tip-b", txs, 1700000001));
// Old wallet A's snapshot was pruned — only the live wallet's row remains.
EXPECT_EQ(cache.snapshotCount(), 1);
auto loadedB = cache.load(idB, 100, "tip-b");
EXPECT_TRUE(loadedB.loaded);
fs::remove_all(dir);
}
void testTransactionHistoryCacheRefreshApply() void testTransactionHistoryCacheRefreshApply()
{ {
using Refresh = dragonx::services::NetworkRefreshService; using Refresh = dragonx::services::NetworkRefreshService;
@@ -3565,6 +3601,74 @@ void testLiteRefreshModelAppliesToWalletState()
EXPECT_FALSE(state.sync.syncing); EXPECT_FALSE(state.sync.syncing);
} }
// A Send record carries its recipient in outgoing_metadata, not the top-level address/memo —
// the transactions list must surface the destination + memo instead of blanks.
void testLiteSendShowsRecipientFromOutgoing()
{
using namespace dragonx::wallet;
LiteWalletRefreshBundle bundle;
bundle.complete = true;
bundle.hasTransactions = true;
LiteTransactionRecord rec;
rec.txid = "sendtx";
rec.datetime = 1700000000;
rec.unconfirmed = true;
rec.direction = LiteTransactionDirection::Send;
rec.address = ""; // sends have no top-level address
rec.memo = "";
LiteTransactionOutput out;
out.address = "zs1recipient";
out.value = 75000000; // 0.75 DRGX
out.memo = "thanks";
rec.outgoingMetadata.push_back(out);
bundle.transactions.transactions.push_back(rec);
const auto mapped = mapLiteWalletRefreshBundle(bundle);
dragonx::WalletState state;
applyLiteRefreshModelToWalletState(mapped.model, state);
EXPECT_EQ(static_cast<int>(state.transactions.size()), 1);
EXPECT_EQ(state.transactions[0].type, std::string("send"));
EXPECT_EQ(state.transactions[0].address, std::string("zs1recipient"));
EXPECT_EQ(state.transactions[0].memo, std::string("thanks"));
}
// A tolerated partial refresh where the notes/utxo command failed (hasAddresses but
// !hasSpendableOutputs) must NOT zero every per-address balance — it keeps the last-known
// values so a correct total isn't accompanied by a misleading all-zero breakdown.
void testLitePartialRefreshKeepsPriorAddressBalances()
{
using namespace dragonx::wallet;
// First, a full refresh establishes a per-address balance.
LiteWalletRefreshBundle full;
full.complete = true;
full.hasAddresses = true;
full.addresses.zAddresses = {"zs1keep"};
full.hasNotes = true;
LiteSpendableOutput note;
note.address = "zs1keep";
note.value = 120000000; // 1.2 DRGX
note.spent = false;
full.notes.unspentNotes.push_back(note);
dragonx::WalletState state;
applyLiteRefreshModelToWalletState(mapLiteWalletRefreshBundle(full).model, state);
EXPECT_EQ(static_cast<int>(state.z_addresses.size()), 1);
EXPECT_NEAR(state.z_addresses[0].balance, 1.2, 1e-9);
// Now a partial refresh: addresses present, notes command failed (hasNotes=false).
LiteWalletRefreshBundle partial;
partial.complete = true;
partial.hasAddresses = true;
partial.addresses.zAddresses = {"zs1keep"};
partial.hasNotes = false; // notes/utxo failed this cycle
applyLiteRefreshModelToWalletState(mapLiteWalletRefreshBundle(partial).model, state);
EXPECT_EQ(static_cast<int>(state.z_addresses.size()), 1);
EXPECT_NEAR(state.z_addresses[0].balance, 1.2, 1e-9); // preserved, not zeroed
}
// M2b: the controller, after a wallet is ready, auto-starts sync and refreshWalletState() // M2b: the controller, after a wallet is ready, auto-starts sync and refreshWalletState()
// pulls balance/addresses/transactions/syncstatus through the shared bridge into WalletState. // pulls balance/addresses/transactions/syncstatus through the shared bridge into WalletState.
void testLiteWalletControllerRefreshPopulatesState() void testLiteWalletControllerRefreshPopulatesState()
@@ -4099,6 +4203,45 @@ void testLiteOfficialServerDetection()
EXPECT_FALSE(isOfficialLiteServer("")); EXPECT_FALSE(isOfficialLiteServer(""));
} }
// H7: atomic + durable file writes (settings.json / addressbook.json persistence).
void testAtomicFileWrite()
{
namespace fs = std::filesystem;
using dragonx::util::Platform;
fs::path dir = fs::temp_directory_path() / "obsidian_atomic_test";
fs::remove_all(dir);
const std::string target = (dir / "nested" / "settings.json").string();
// Creates parent dirs and writes content.
EXPECT_TRUE(Platform::writeFileAtomically(target, "{\"a\":1}"));
EXPECT_TRUE(fs::exists(target));
{
std::ifstream in(target);
std::string body((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
EXPECT_EQ(body, std::string("{\"a\":1}"));
}
// Overwrites in place (atomic replace), and leaves no stray .tmp behind.
EXPECT_TRUE(Platform::writeFileAtomically(target, "{\"a\":2,\"b\":3}"));
{
std::ifstream in(target);
std::string body((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
EXPECT_EQ(body, std::string("{\"a\":2,\"b\":3}"));
}
EXPECT_FALSE(fs::exists(target + ".tmp"));
#ifndef _WIN32
// restrictPermissions -> owner-only (0600).
const std::string secret = (dir / "vault.bin").string();
EXPECT_TRUE(Platform::writeFileAtomically(secret, "shh", /*restrictPermissions=*/true));
auto perms = fs::status(secret).permissions();
EXPECT_TRUE((perms & (fs::perms::group_all | fs::perms::others_all)) == fs::perms::none);
#endif
fs::remove_all(dir);
}
// Live probe of a real lite server (env-gated). Validates CONNECT_ONLY latency + IP capture. // Live probe of a real lite server (env-gated). Validates CONNECT_ONLY latency + IP capture.
void testLiteServerProbeLive() void testLiteServerProbeLive()
{ {
@@ -4243,6 +4386,7 @@ int main()
testBalanceAddressListModel(); testBalanceAddressListModel();
testExplorerBlockCache(); testExplorerBlockCache();
testTransactionHistoryCache(); testTransactionHistoryCache();
testTransactionHistoryCachePrunesOldWallets();
testTransactionHistoryCacheRefreshApply(); testTransactionHistoryCacheRefreshApply();
testLiteBridgeOwnedStringCopiesBeforeFreeOnSuccess(); testLiteBridgeOwnedStringCopiesBeforeFreeOnSuccess();
testLiteBridgeOwnedStringClassifiesNullWithoutFree(); testLiteBridgeOwnedStringClassifiesNullWithoutFree();
@@ -4256,6 +4400,8 @@ int main()
testLiteWalletControllerEncryption(); testLiteWalletControllerEncryption();
testLiteChainNameMigration(); testLiteChainNameMigration();
testLiteRefreshModelAppliesToWalletState(); testLiteRefreshModelAppliesToWalletState();
testLiteSendShowsRecipientFromOutgoing();
testLitePartialRefreshKeepsPriorAddressBalances();
testLitePerAddressBalances(); testLitePerAddressBalances();
testLiteWalletControllerNewAddress(); testLiteWalletControllerNewAddress();
testLiteSyncStatusParserRealShapes(); testLiteSyncStatusParserRealShapes();
@@ -4279,6 +4425,7 @@ int main()
testXmrigSignatureVerify(); testXmrigSignatureVerify();
testLiteServerHostParsing(); testLiteServerHostParsing();
testLiteOfficialServerDetection(); testLiteOfficialServerDetection();
testAtomicFileWrite();
testLiteServerProbeLive(); testLiteServerProbeLive();
testXmrigLiveInstall(); testXmrigLiveInstall();
testGeneratedResourceBehavior(); testGeneratedResourceBehavior();