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:
@@ -20,6 +20,7 @@
|
||||
#include "ui/windows/mining_tab_helpers.h"
|
||||
#include "util/amount_format.h"
|
||||
#include "util/payment_uri.h"
|
||||
#include "util/platform.h"
|
||||
#include "util/xmrig_updater.h"
|
||||
#include "util/lite_server_probe.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.failureMessages.size(), static_cast<size_t>(1));
|
||||
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[0], std::string("op-stale"));
|
||||
|
||||
@@ -2640,6 +2644,38 @@ void testTransactionHistoryCache()
|
||||
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()
|
||||
{
|
||||
using Refresh = dragonx::services::NetworkRefreshService;
|
||||
@@ -3565,6 +3601,74 @@ void testLiteRefreshModelAppliesToWalletState()
|
||||
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()
|
||||
// pulls balance/addresses/transactions/syncstatus through the shared bridge into WalletState.
|
||||
void testLiteWalletControllerRefreshPopulatesState()
|
||||
@@ -4099,6 +4203,45 @@ void testLiteOfficialServerDetection()
|
||||
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.
|
||||
void testLiteServerProbeLive()
|
||||
{
|
||||
@@ -4243,6 +4386,7 @@ int main()
|
||||
testBalanceAddressListModel();
|
||||
testExplorerBlockCache();
|
||||
testTransactionHistoryCache();
|
||||
testTransactionHistoryCachePrunesOldWallets();
|
||||
testTransactionHistoryCacheRefreshApply();
|
||||
testLiteBridgeOwnedStringCopiesBeforeFreeOnSuccess();
|
||||
testLiteBridgeOwnedStringClassifiesNullWithoutFree();
|
||||
@@ -4256,6 +4400,8 @@ int main()
|
||||
testLiteWalletControllerEncryption();
|
||||
testLiteChainNameMigration();
|
||||
testLiteRefreshModelAppliesToWalletState();
|
||||
testLiteSendShowsRecipientFromOutgoing();
|
||||
testLitePartialRefreshKeepsPriorAddressBalances();
|
||||
testLitePerAddressBalances();
|
||||
testLiteWalletControllerNewAddress();
|
||||
testLiteSyncStatusParserRealShapes();
|
||||
@@ -4279,6 +4425,7 @@ int main()
|
||||
testXmrigSignatureVerify();
|
||||
testLiteServerHostParsing();
|
||||
testLiteOfficialServerDetection();
|
||||
testAtomicFileWrite();
|
||||
testLiteServerProbeLive();
|
||||
testXmrigLiveInstall();
|
||||
testGeneratedResourceBehavior();
|
||||
|
||||
Reference in New Issue
Block a user