diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index c9d3dc5..18c0d54 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -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(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(1)); + EXPECT_EQ(parsed.failureByOpid.at("op-failed"), std::string("bad memo")); EXPECT_EQ(parsed.staleOpids.size(), static_cast(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 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(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(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(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(in)), std::istreambuf_iterator()); + 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(in)), std::istreambuf_iterator()); + 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();