test(app): add headless ViewModel, CLI and localization tests
ViewModels run on Avalonia.Headless: wizard flows, password and file-lock handling, seed and private-key reveal gating, contacts, settings, sync and send against the fake server. The CLI entry point is invoked in-process end to end. Localization checks cover the 7 languages, placeholders and keys used in sources. Internal seams (UpdateCheck, ProfileOverride) keep the tests offline.
This commit is contained in:
1 parent
54d07f8e92
commit
530b7feaa5
11 files changed
+1022
-3
No files matched your search
@@ -27,4 +27,8 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\Core\PalladiumWallet.Core.csproj" />
|
<ProjectReference Include="..\Core\PalladiumWallet.Core.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<InternalsVisibleTo Include="PalladiumWallet.Tests" />
|
||||||
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
@@ -20,13 +20,17 @@ public partial class MainWindowViewModel
|
|||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void CloseUpdateAvailable() => IsUpdateAvailableOpen = false;
|
private void CloseUpdateAvailable() => IsUpdateAvailableOpen = false;
|
||||||
|
|
||||||
|
/// <summary>Test seam: replaces the real GitHub check so tests never touch the network.</summary>
|
||||||
|
internal static System.Func<string, Task<LatestRelease?>> UpdateCheck { get; set; } =
|
||||||
|
version => UpdateChecker.CheckAsync(version);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Fire-and-forget check run once at startup. Best-effort: any failure or an
|
/// Fire-and-forget check run once at startup. Best-effort: any failure or an
|
||||||
/// up-to-date app silently does nothing (see <see cref="UpdateChecker"/>).
|
/// up-to-date app silently does nothing (see <see cref="UpdateChecker"/>).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task CheckForUpdatesAsync()
|
private async Task CheckForUpdatesAsync()
|
||||||
{
|
{
|
||||||
var latest = await UpdateChecker.CheckAsync(AppVersion);
|
var latest = await UpdateCheck(AppVersion);
|
||||||
if (latest is null) return;
|
if (latest is null) return;
|
||||||
|
|
||||||
UpdateReleaseUrl = latest.HtmlUrl;
|
UpdateReleaseUrl = latest.HtmlUrl;
|
||||||
|
|||||||
@@ -130,7 +130,10 @@ public partial class MainWindowViewModel : ViewModelBase
|
|||||||
// ---- helpers ----
|
// ---- helpers ----
|
||||||
|
|
||||||
private NetKind Net => NetKind.Mainnet;
|
private NetKind Net => NetKind.Mainnet;
|
||||||
private ChainProfile Profile => ChainProfiles.For(Net);
|
/// <summary>Test seam: lets tests drop the real bootstrap servers so nothing reaches the internet.</summary>
|
||||||
|
internal static ChainProfile? ProfileOverride { get; set; }
|
||||||
|
|
||||||
|
private ChainProfile Profile => ProfileOverride ?? ChainProfiles.For(Net);
|
||||||
private ServerRegistry Registry => new(Profile, AppPaths.ServersPath(Net));
|
private ServerRegistry Registry => new(Profile, AppPaths.ServersPath(Net));
|
||||||
|
|
||||||
private string Fmt(long sats, bool withLabel = true) =>
|
private string Fmt(long sats, bool withLabel = true) =>
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using NBitcoin;
|
||||||
|
using PalladiumWallet.Core.Chain;
|
||||||
|
using PalladiumWallet.Core.Crypto;
|
||||||
|
using PalladiumWallet.Core.Spv;
|
||||||
|
using PalladiumWallet.Core.Storage;
|
||||||
|
using PalladiumWallet.Tests.Net;
|
||||||
|
|
||||||
|
namespace PalladiumWallet.Tests.App;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// End-to-end tests of the CLI: the real entry point is invoked in-process with a captured
|
||||||
|
/// console. State is sandboxed: wallet files live in a temp folder (--file) and the data
|
||||||
|
/// root (server list, certificate pins) is redirected to it.
|
||||||
|
/// </summary>
|
||||||
|
[Collection("AppPaths")] // Console.SetOut and AppPaths are process-wide
|
||||||
|
public sealed class CliTests : IDisposable
|
||||||
|
{
|
||||||
|
private const string Mnemonic =
|
||||||
|
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
|
||||||
|
|
||||||
|
private readonly string _dir = Path.Combine(Path.GetTempPath(), $"plm-cli-{Guid.NewGuid()}");
|
||||||
|
private readonly string? _savedOverride = AppPaths.OverrideDataRoot;
|
||||||
|
|
||||||
|
public CliTests()
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(_dir);
|
||||||
|
AppPaths.OverrideDataRoot = _dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
AppPaths.OverrideDataRoot = _savedOverride;
|
||||||
|
try { Directory.Delete(_dir, recursive: true); } catch { /* best effort */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
private string WalletFile(string name = "w.json") => Path.Combine(_dir, name);
|
||||||
|
|
||||||
|
private static async Task<(int Exit, string Out, string Err)> Cli(params string[] args)
|
||||||
|
{
|
||||||
|
var entry = Assembly.Load("PalladiumWallet.Cli").EntryPoint
|
||||||
|
?? throw new InvalidOperationException("CLI has no entry point.");
|
||||||
|
var (oldOut, oldErr) = (Console.Out, Console.Error);
|
||||||
|
var (o, e) = (new StringWriter(), new StringWriter());
|
||||||
|
Console.SetOut(o); Console.SetError(e);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// The top-level-statements entry point is a synchronous Main(string[]) → int wrapper.
|
||||||
|
var exit = await Task.Run(() => (int)entry.Invoke(null, [args])!);
|
||||||
|
return (exit, o.ToString(), e.ToString());
|
||||||
|
}
|
||||||
|
finally { Console.SetOut(oldOut); Console.SetError(oldErr); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task NoArguments_PrintsUsage_AndFails()
|
||||||
|
{
|
||||||
|
var (exit, output, _) = await Cli();
|
||||||
|
Assert.Equal(1, exit);
|
||||||
|
Assert.Contains("PalladiumWallet CLI", output);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("12", 12)]
|
||||||
|
[InlineData("24", 24)]
|
||||||
|
public async Task NewSeed_GeneratesAValidMnemonicOfTheRequestedLength(string words, int count)
|
||||||
|
{
|
||||||
|
var (exit, output, _) = await Cli("newseed", "--words", words);
|
||||||
|
Assert.Equal(0, exit);
|
||||||
|
Assert.True(Bip39.TryParse(output.Trim(), out var m));
|
||||||
|
Assert.Equal(count, m!.Words.Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Addresses_MatchesTheCoreDerivation()
|
||||||
|
{
|
||||||
|
var (exit, output, _) = await Cli("addresses", Mnemonic, "--net", "regtest", "--count", "3");
|
||||||
|
Assert.Equal(0, exit);
|
||||||
|
Assert.True(Bip39.TryParse(Mnemonic, out var m));
|
||||||
|
var account = HdAccount.FromMnemonic(m!, null, ScriptKind.NativeSegwit, ChainProfiles.Regtest);
|
||||||
|
for (var i = 0; i < 3; i++)
|
||||||
|
Assert.Contains(account.GetReceiveAddress(i).ToString(), output);
|
||||||
|
Assert.Contains(account.ToSlip132(), output);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Restore_WithAnInvalidMnemonic_FailsWithoutWritingAFile()
|
||||||
|
{
|
||||||
|
var (exit, _, err) = await Cli("restore", "abandon abandon abandon", "--file", WalletFile());
|
||||||
|
Assert.Equal(1, exit);
|
||||||
|
Assert.Contains("Invalid mnemonic", err);
|
||||||
|
Assert.False(File.Exists(WalletFile()));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Restore_Encrypts_AndInfoNeedsThePassword()
|
||||||
|
{
|
||||||
|
var (exit, _, _) = await Cli("restore", Mnemonic, "--net", "regtest", "--password", "pw", "--file", WalletFile());
|
||||||
|
Assert.Equal(0, exit);
|
||||||
|
Assert.True(EncryptedFile.IsEncrypted(File.ReadAllText(WalletFile())));
|
||||||
|
Assert.DoesNotContain("abandon", File.ReadAllText(WalletFile()));
|
||||||
|
|
||||||
|
var (okExit, info, _) = await Cli("info", "--net", "regtest", "--password", "pw", "--file", WalletFile());
|
||||||
|
Assert.Equal(0, okExit);
|
||||||
|
Assert.Contains("watch-only: False", info);
|
||||||
|
|
||||||
|
var (badExit, _, err) = await Cli("info", "--net", "regtest", "--password", "wrong", "--file", WalletFile());
|
||||||
|
Assert.Equal(1, badExit);
|
||||||
|
Assert.Contains("Error:", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_ShowsTheMnemonicOnce_AndWritesTheWallet()
|
||||||
|
{
|
||||||
|
var (exit, output, _) = await Cli("create", "--net", "regtest", "--file", WalletFile());
|
||||||
|
Assert.Equal(0, exit);
|
||||||
|
Assert.Contains("New mnemonic", output);
|
||||||
|
Assert.True(File.Exists(WalletFile()));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RestoreXpub_AndRestoreAddress_ProduceWatchOnlyWallets()
|
||||||
|
{
|
||||||
|
Assert.True(Bip39.TryParse(Mnemonic, out var m));
|
||||||
|
var account = HdAccount.FromMnemonic(m!, null, ScriptKind.NativeSegwit, ChainProfiles.Regtest);
|
||||||
|
|
||||||
|
var (e1, _, _) = await Cli("restore-xpub", account.ToSlip132(), "--net", "regtest", "--file", WalletFile("x.json"));
|
||||||
|
Assert.Equal(0, e1);
|
||||||
|
Assert.Contains("watch-only: True", (await Cli("info", "--net", "regtest", "--file", WalletFile("x.json"))).Out);
|
||||||
|
|
||||||
|
var (e2, _, _) = await Cli("restore-address", account.GetReceiveAddress(0).ToString(), "--net", "regtest", "--file", WalletFile("a.json"));
|
||||||
|
Assert.Equal(0, e2);
|
||||||
|
Assert.Contains("watch-only: True", (await Cli("info", "--net", "regtest", "--file", WalletFile("a.json"))).Out);
|
||||||
|
|
||||||
|
var (e3, _, err3) = await Cli("restore-xpub", "garbage", "--net", "regtest", "--file", WalletFile("bad.json"));
|
||||||
|
Assert.Equal(1, e3);
|
||||||
|
Assert.Contains("not recognised", err3);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Send_BeforeAnySync_AsksToSyncFirst()
|
||||||
|
{
|
||||||
|
await Cli("restore", Mnemonic, "--net", "regtest", "--file", WalletFile());
|
||||||
|
var (exit, _, err) = await Cli("send", "--to", "x", "--all", "--net", "regtest", "--file", WalletFile());
|
||||||
|
Assert.Equal(1, exit);
|
||||||
|
Assert.Contains("run 'sync' first", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Sync_ThenSend_AgainstAFakeServer_ProducesASignedTransaction()
|
||||||
|
{
|
||||||
|
var net = PalladiumNetworks.Regtest;
|
||||||
|
Assert.True(Bip39.TryParse(Mnemonic, out var mnemonic));
|
||||||
|
var account = HdAccount.FromMnemonic(mnemonic!, null, ScriptKind.NativeSegwit, ChainProfiles.Regtest);
|
||||||
|
var funding = net.CreateTransaction();
|
||||||
|
funding.Inputs.Add(new TxIn(new OutPoint(uint256.One, 0)));
|
||||||
|
funding.Outputs.Add(Money.Satoshis(10_000_000), account.GetReceiveAddress(0));
|
||||||
|
var txid = funding.GetHash();
|
||||||
|
var scripthash = Scripthash.FromAddress(account.GetReceiveAddress(0));
|
||||||
|
|
||||||
|
string Header(uint256 root, int h)
|
||||||
|
{
|
||||||
|
var header = net.Consensus.ConsensusFactory.CreateBlockHeader();
|
||||||
|
header.HashPrevBlock = uint256.Zero;
|
||||||
|
header.HashMerkleRoot = root;
|
||||||
|
header.BlockTime = DateTimeOffset.FromUnixTimeSeconds(1_700_000_000 + h);
|
||||||
|
header.Bits = new Target(0x1d00ffffu);
|
||||||
|
header.Nonce = (uint)h;
|
||||||
|
return Convert.ToHexString(header.ToBytes()).ToLowerInvariant();
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var server = new FakeElectrumServer();
|
||||||
|
server.Handle("blockchain.headers.subscribe", _ => new { height = 300, hex = Header(uint256.One, 300) });
|
||||||
|
server.Handle("blockchain.scripthash.subscribe", _ => null);
|
||||||
|
server.Handle("blockchain.scripthash.get_history", p =>
|
||||||
|
p[0].GetString() == scripthash ? new[] { new { tx_hash = txid.ToString(), height = 100 } } : Array.Empty<object>());
|
||||||
|
server.Handle("blockchain.transaction.get", _ => funding.ToHex());
|
||||||
|
server.Handle("blockchain.transaction.get_merkle", _ => new { block_height = 100, pos = 0, merkle = Array.Empty<string>() });
|
||||||
|
server.Handle("blockchain.block.header", _ => Header(txid, 100));
|
||||||
|
|
||||||
|
await Cli("restore", Mnemonic, "--net", "regtest", "--file", WalletFile());
|
||||||
|
var serverArg = $"{server.Host}:{server.Port}";
|
||||||
|
|
||||||
|
var (syncExit, syncOut, _) = await Cli("sync", "--server", serverArg, "--net", "regtest", "--file", WalletFile());
|
||||||
|
Assert.Equal(0, syncExit);
|
||||||
|
Assert.Contains("0.1", syncOut); // 10,000,000 sat
|
||||||
|
Assert.Contains(txid.ToString(), syncOut);
|
||||||
|
Assert.DoesNotContain("(unverified)", syncOut);
|
||||||
|
|
||||||
|
var dest = account.GetReceiveAddress(9).ToString();
|
||||||
|
var (sendExit, sendOut, _) = await Cli("send", "--to", dest, "--amount", "0.05", "--feerate", "2",
|
||||||
|
"--server", serverArg, "--net", "regtest", "--file", WalletFile());
|
||||||
|
Assert.Equal(0, sendExit);
|
||||||
|
Assert.Contains("NOT broadcast", sendOut);
|
||||||
|
var hex = sendOut.Split('\n', StringSplitOptions.TrimEntries).Last(l => l.Length > 100 && l.All(Uri.IsHexDigit));
|
||||||
|
var tx = Transaction.Parse(hex, net);
|
||||||
|
Assert.Contains(tx.Outputs, o => o.Value == Money.Satoshis(5_000_000) && o.ScriptPubKey == account.GetReceiveAddress(9).ScriptPubKey);
|
||||||
|
Assert.Equal(txid, tx.Inputs[0].PrevOut.Hash);
|
||||||
|
Assert.Equal(0, server.CallCount("blockchain.transaction.broadcast")); // nothing broadcast without --broadcast
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Headless;
|
||||||
|
using PalladiumWallet.Tests.App;
|
||||||
|
|
||||||
|
// Boots the real Avalonia Application headlessly (no window, no GPU) so ViewModels that use
|
||||||
|
// the dispatcher (DispatcherTimer, UIThread.Post) can be tested. The xunit-v3-only helper
|
||||||
|
// package (Avalonia.Headless.XUnit) is deliberately not used: this suite is on xunit 2.
|
||||||
|
[assembly: AvaloniaTestApplication(typeof(TestAppBuilder))]
|
||||||
|
|
||||||
|
namespace PalladiumWallet.Tests.App;
|
||||||
|
|
||||||
|
public class TestAppBuilder
|
||||||
|
{
|
||||||
|
public static AppBuilder BuildAvaloniaApp() =>
|
||||||
|
AppBuilder.Configure<global::PalladiumWallet.App.App>()
|
||||||
|
.UseHeadless(new AvaloniaHeadlessPlatformOptions());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One shared headless Avalonia session; tests run their body on its UI thread.</summary>
|
||||||
|
internal static class UiSession
|
||||||
|
{
|
||||||
|
private static readonly Lazy<HeadlessUnitTestSession> Session =
|
||||||
|
new(() => HeadlessUnitTestSession.StartNew(typeof(TestAppBuilder)));
|
||||||
|
|
||||||
|
public static Task Run(Func<Task> body) => Session.Value.Dispatch(body, CancellationToken.None);
|
||||||
|
|
||||||
|
public static Task Run(Action body) =>
|
||||||
|
Session.Value.Dispatch(() => { body(); return Task.CompletedTask; }, CancellationToken.None);
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using PalladiumWallet.App.Localization;
|
||||||
|
|
||||||
|
namespace PalladiumWallet.Tests.App;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Consistency of the in-code translation table: every key must have a non-empty value for
|
||||||
|
/// each of the 7 languages, format placeholders must match across languages, and every key
|
||||||
|
/// referenced from XAML or C# must exist (a missing key silently renders as the raw key).
|
||||||
|
/// </summary>
|
||||||
|
[Collection("AppPaths")] // shares the serialized collection for process-wide statics (Loc.Instance, AppPaths)
|
||||||
|
public partial class LocalizationTests
|
||||||
|
{
|
||||||
|
private static Dictionary<string, string[]> Table() =>
|
||||||
|
(Dictionary<string, string[]>)typeof(Loc)
|
||||||
|
.GetField("Strings", BindingFlags.NonPublic | BindingFlags.Static)!.GetValue(null)!;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EveryKey_HasOneNonEmptyValuePerLanguage()
|
||||||
|
{
|
||||||
|
var bad = Table()
|
||||||
|
.Where(kv => kv.Value.Length != Loc.Languages.Length || kv.Value.Any(string.IsNullOrWhiteSpace))
|
||||||
|
.Select(kv => kv.Key).ToList();
|
||||||
|
Assert.True(bad.Count == 0, "Incomplete translations: " + string.Join(", ", bad));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LanguageNames_MatchLanguages() =>
|
||||||
|
Assert.Equal(Loc.Languages.Length, Loc.LanguageNames.Length);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FormatPlaceholders_AreIdenticalAcrossLanguages()
|
||||||
|
{
|
||||||
|
var bad = new List<string>();
|
||||||
|
foreach (var (key, values) in Table())
|
||||||
|
{
|
||||||
|
var sets = values.Select(v => string.Join(",", Placeholder().Matches(v).Select(m => m.Value).Distinct().Order())).ToList();
|
||||||
|
if (sets.Distinct().Count() > 1) bad.Add($"{key}: {string.Join(" | ", sets)}");
|
||||||
|
}
|
||||||
|
Assert.True(bad.Count == 0, "Placeholder mismatch: " + string.Join("; ", bad));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Indexer_ReturnsTranslationPerLanguage_AndFallsBackToKey()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var table = Table();
|
||||||
|
var key = table.Keys.First();
|
||||||
|
for (var i = 0; i < Loc.Languages.Length; i++)
|
||||||
|
Assert.Equal(table[key][i], Loc.SwitchTo(Loc.Languages[i])[key]);
|
||||||
|
Assert.Equal("no.such.key", Loc.Tr("no.such.key"));
|
||||||
|
Assert.Equal("en", Loc.SwitchTo("xx").Language); // unknown language → English
|
||||||
|
}
|
||||||
|
finally { Loc.SwitchTo("en"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void KeysUsedInSources_ExistInTable()
|
||||||
|
{
|
||||||
|
var root = RepoRoot();
|
||||||
|
if (root is null) return; // running from a packaged copy without sources
|
||||||
|
var keys = Table().Keys.ToHashSet();
|
||||||
|
var missing = new SortedSet<string>();
|
||||||
|
foreach (var file in Directory.EnumerateFiles(Path.Combine(root, "src", "App"), "*.*", SearchOption.AllDirectories)
|
||||||
|
.Where(f => (f.EndsWith(".axaml") || f.EndsWith(".cs")) && !f.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}")
|
||||||
|
&& !f.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}")
|
||||||
|
&& Path.GetFileName(f) != "Loc.cs")) // Loc.cs only mentions the syntax in comments
|
||||||
|
{
|
||||||
|
var text = File.ReadAllText(file);
|
||||||
|
foreach (Match m in XamlKey().Matches(text)) if (!keys.Contains(m.Groups[1].Value)) missing.Add(m.Groups[1].Value);
|
||||||
|
foreach (Match m in CodeKey().Matches(text)) if (!keys.Contains(m.Groups[1].Value)) missing.Add(m.Groups[1].Value);
|
||||||
|
}
|
||||||
|
Assert.True(missing.Count == 0, "Keys used but not defined: " + string.Join(", ", missing));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? RepoRoot()
|
||||||
|
{
|
||||||
|
for (var d = new DirectoryInfo(AppContext.BaseDirectory); d is not null; d = d.Parent)
|
||||||
|
if (File.Exists(Path.Combine(d.FullName, "PalladiumWallet.sln"))) return d.FullName;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
[GeneratedRegex(@"\{\d+(?::[^}]*)?\}")] private static partial Regex Placeholder();
|
||||||
|
[GeneratedRegex(@"Loc\[([A-Za-z0-9_.]+)\]")] private static partial Regex XamlKey();
|
||||||
|
[GeneratedRegex(@"Loc\.Tr\(""([A-Za-z0-9_.]+)""\)")] private static partial Regex CodeKey();
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
using PalladiumWallet.App.Localization;
|
||||||
|
using PalladiumWallet.App.ViewModels;
|
||||||
|
using PalladiumWallet.Core.Chain;
|
||||||
|
using PalladiumWallet.Core.Crypto;
|
||||||
|
using PalladiumWallet.Core.Storage;
|
||||||
|
using PalladiumWallet.Core.Wallet;
|
||||||
|
|
||||||
|
namespace PalladiumWallet.Tests.App;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Secret-handling gates in the UI layer: the seed and private keys must stay hidden until the
|
||||||
|
/// wallet password is supplied, and must be hidden again on close. Also settings, wallet info
|
||||||
|
/// and the remaining wizard flows.
|
||||||
|
/// </summary>
|
||||||
|
[Collection("AppPaths")]
|
||||||
|
public sealed class MainWindowViewModelSecurityTests : VmTestBase
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public Task Seed_StaysHidden_UntilTheRightPasswordIsGiven() => UiSession.Run(() =>
|
||||||
|
{
|
||||||
|
var vm = new MainWindowViewModel();
|
||||||
|
RestoreFlow(vm, Mnemonic, "pw", "s1");
|
||||||
|
vm.OpenWalletInfoCommand.Execute(null);
|
||||||
|
Assert.True(vm.WalletInfoHasSeed);
|
||||||
|
Assert.True(vm.WalletInfoSeedNeedsPassword);
|
||||||
|
Assert.Equal("", vm.WalletInfoSeedText);
|
||||||
|
|
||||||
|
vm.WalletInfoSeedPasswordInput = "nope";
|
||||||
|
vm.RevealSeedCommand.Execute(null);
|
||||||
|
Assert.False(vm.IsWalletInfoSeedRevealed);
|
||||||
|
Assert.Equal(Loc.Tr("msg.wrongpassword"), vm.WalletInfoSeedError);
|
||||||
|
Assert.Equal("", vm.WalletInfoSeedText);
|
||||||
|
|
||||||
|
vm.WalletInfoSeedPasswordInput = "pw";
|
||||||
|
vm.RevealSeedCommand.Execute(null);
|
||||||
|
Assert.True(vm.IsWalletInfoSeedRevealed);
|
||||||
|
Assert.Equal(Mnemonic, vm.WalletInfoSeedText);
|
||||||
|
|
||||||
|
vm.CloseWalletInfoCommand.Execute(null); // closing wipes the revealed state
|
||||||
|
Assert.False(vm.IsWalletInfoSeedRevealed);
|
||||||
|
Assert.Equal("", vm.WalletInfoSeedText);
|
||||||
|
});
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public Task WatchOnlyWallet_HasNoSeedToReveal() => UiSession.Run(() =>
|
||||||
|
{
|
||||||
|
var vm = new MainWindowViewModel();
|
||||||
|
var address = HdAccount.FromMnemonic(ParseMnemonic(), null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet)
|
||||||
|
.GetReceiveAddress(0).ToString();
|
||||||
|
vm.WizardStartImportAddressCommand.Execute(null);
|
||||||
|
vm.ImportAddressInput = address;
|
||||||
|
vm.WizardNextFromImportAddressCommand.Execute(null);
|
||||||
|
vm.EncryptWallet = false;
|
||||||
|
vm.CreateOrRestoreCommand.Execute(null);
|
||||||
|
vm.OpenWalletInfoCommand.Execute(null);
|
||||||
|
|
||||||
|
Assert.False(vm.WalletInfoHasSeed);
|
||||||
|
vm.RevealSeedCommand.Execute(null);
|
||||||
|
Assert.False(vm.IsWalletInfoSeedRevealed);
|
||||||
|
});
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public Task PrivateKey_IsRevealedOnlyAfterThePassword_AndOnlyWhenTheWalletHasKeys() => UiSession.Run(async () =>
|
||||||
|
{
|
||||||
|
var account = HdAccount.FromMnemonic(ParseMnemonic(), null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet);
|
||||||
|
WireFundedChain(account.GetReceiveAddress(0), 1_000_000);
|
||||||
|
var vm = new MainWindowViewModel();
|
||||||
|
RestoreFlow(vm, Mnemonic, "pw", "pk");
|
||||||
|
await Eventually(() => !vm.IsSyncing && vm.Addresses.Count > 0);
|
||||||
|
|
||||||
|
var row = vm.Addresses.First(a => !a.IsChange && a.Indice == 0);
|
||||||
|
Assert.True(row.HasPrivKey);
|
||||||
|
vm.ShowAddressInfo(row);
|
||||||
|
|
||||||
|
vm.RequestPrivKeyRevealCommand.Execute(null);
|
||||||
|
Assert.True(vm.IsPrivKeyPromptOpen);
|
||||||
|
Assert.False(vm.IsAddressPrivKeyRevealed);
|
||||||
|
|
||||||
|
vm.PrivKeyPromptPassword = "wrong";
|
||||||
|
vm.ConfirmPrivKeyPasswordCommand.Execute(null);
|
||||||
|
Assert.False(vm.IsAddressPrivKeyRevealed);
|
||||||
|
Assert.Equal(Loc.Tr("msg.wrongpassword"), vm.PrivKeyPromptError);
|
||||||
|
|
||||||
|
vm.PrivKeyPromptPassword = "pw";
|
||||||
|
vm.ConfirmPrivKeyPasswordCommand.Execute(null);
|
||||||
|
Assert.True(vm.IsAddressPrivKeyRevealed);
|
||||||
|
Assert.False(vm.IsPrivKeyPromptOpen);
|
||||||
|
|
||||||
|
vm.CloseAddressInfoCommand.Execute(null); // closing the sheet re-hides everything
|
||||||
|
vm.ShowAddressInfo(row);
|
||||||
|
Assert.False(vm.IsAddressPrivKeyRevealed);
|
||||||
|
});
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public Task WatchOnlyAddressRows_NeverCarryAPrivateKey() => UiSession.Run(async () =>
|
||||||
|
{
|
||||||
|
var account = HdAccount.FromMnemonic(ParseMnemonic(), null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet);
|
||||||
|
WireFundedChain(account.GetReceiveAddress(0), 1_000_000);
|
||||||
|
var vm = new MainWindowViewModel();
|
||||||
|
vm.WizardStartImportXkeyCommand.Execute(null);
|
||||||
|
vm.ImportXkeyInput = account.ToSlip132();
|
||||||
|
vm.WizardNextFromImportXkeyCommand.Execute(null);
|
||||||
|
vm.EncryptWallet = false;
|
||||||
|
vm.CreateOrRestoreCommand.Execute(null);
|
||||||
|
await Eventually(() => !vm.IsSyncing && vm.Addresses.Count > 0);
|
||||||
|
|
||||||
|
Assert.All(vm.Addresses, a => Assert.False(a.HasPrivKey));
|
||||||
|
vm.ShowAddressInfo(vm.Addresses[0]);
|
||||||
|
vm.RequestPrivKeyRevealCommand.Execute(null);
|
||||||
|
Assert.False(vm.IsAddressPrivKeyRevealed);
|
||||||
|
Assert.False(vm.IsPrivKeyPromptOpen);
|
||||||
|
});
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public Task Units_AndLanguages_AreAppliedAndPersisted() => UiSession.Run(() =>
|
||||||
|
{
|
||||||
|
var vm = new MainWindowViewModel();
|
||||||
|
|
||||||
|
vm.SetUnitCommand.Execute("sat");
|
||||||
|
Assert.True(vm.IsUnitSat);
|
||||||
|
Assert.Equal("sat", vm.UnitLabel);
|
||||||
|
Assert.Equal("sat", AppConfig.Load(AppPaths.ConfigPath()).Unit);
|
||||||
|
|
||||||
|
foreach (var lang in Loc.Languages)
|
||||||
|
{
|
||||||
|
vm.SetLanguageCommand.Execute(lang);
|
||||||
|
Assert.Equal(lang, vm.Loc.Language);
|
||||||
|
}
|
||||||
|
Assert.True(vm.IsLangZh);
|
||||||
|
Assert.False(vm.IsLangIt);
|
||||||
|
});
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public Task SettingsFromDisk_AreRestoredByANewViewModel() => UiSession.Run(() =>
|
||||||
|
{
|
||||||
|
new MainWindowViewModel().SetLanguageCommand.Execute("de");
|
||||||
|
|
||||||
|
var vm2 = new MainWindowViewModel();
|
||||||
|
|
||||||
|
Assert.Equal("de", vm2.Loc.Language);
|
||||||
|
Assert.True(vm2.IsLangDe);
|
||||||
|
});
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public Task Wizard_BackNavigation_ReturnsToThePreviousStep() => UiSession.Run(() =>
|
||||||
|
{
|
||||||
|
var vm = new MainWindowViewModel();
|
||||||
|
vm.WizardStartRestoreCommand.Execute(null);
|
||||||
|
vm.MnemonicInput = Mnemonic;
|
||||||
|
vm.WizardNextFromWordsCommand.Execute(null);
|
||||||
|
Assert.True(vm.IsStepPassphrase);
|
||||||
|
|
||||||
|
vm.WizardBackCommand.Execute(null);
|
||||||
|
Assert.True(vm.IsStepWords);
|
||||||
|
vm.WizardBackCommand.Execute(null);
|
||||||
|
Assert.True(vm.IsStepStart);
|
||||||
|
});
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public Task ImportWif_CreatesAnImportedKeyWallet_AndRejectsGarbage() => UiSession.Run(() =>
|
||||||
|
{
|
||||||
|
var key = new NBitcoin.Key();
|
||||||
|
var wif = key.GetWif(PalladiumNetworks.Mainnet).ToString();
|
||||||
|
var vm = new MainWindowViewModel();
|
||||||
|
|
||||||
|
vm.WizardStartImportWifCommand.Execute(null);
|
||||||
|
vm.ImportWifInput = "definitely-not-a-wif";
|
||||||
|
vm.WizardNextFromImportWifCommand.Execute(null);
|
||||||
|
Assert.True(vm.IsStepImportWif);
|
||||||
|
|
||||||
|
vm.ImportWifInput = wif;
|
||||||
|
vm.WizardNextFromImportWifCommand.Execute(null);
|
||||||
|
vm.EncryptWallet = true;
|
||||||
|
vm.PasswordInput = vm.ConfirmPasswordInput = "pw";
|
||||||
|
vm.CreateOrRestoreCommand.Execute(null);
|
||||||
|
|
||||||
|
Assert.True(vm.IsWalletOpen);
|
||||||
|
Assert.Contains("imported", vm.NetworkInfo);
|
||||||
|
var stored = WalletStore.Load(Assert.Single(AppPaths.WalletFiles(NetKind.Mainnet)), "pw");
|
||||||
|
Assert.Equal(wif, Assert.Single(stored.WifKeys!));
|
||||||
|
});
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public Task ImportXprv_CreatesASpendableWallet() => UiSession.Run(() =>
|
||||||
|
{
|
||||||
|
var account = HdAccount.FromMnemonic(ParseMnemonic(), null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet);
|
||||||
|
var vm = new MainWindowViewModel();
|
||||||
|
vm.WizardStartImportXkeyCommand.Execute(null);
|
||||||
|
vm.ImportXkeyInput = account.ToSlip132Private();
|
||||||
|
vm.WizardNextFromImportXkeyCommand.Execute(null);
|
||||||
|
vm.EncryptWallet = false;
|
||||||
|
vm.CreateOrRestoreCommand.Execute(null);
|
||||||
|
|
||||||
|
Assert.True(vm.IsWalletOpen);
|
||||||
|
Assert.False(vm.IsWatchOnlyAccount);
|
||||||
|
});
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public Task ClosingTheWallet_ReleasesTheLock_SoItCanBeReopened() => UiSession.Run(() =>
|
||||||
|
{
|
||||||
|
var vm = new MainWindowViewModel();
|
||||||
|
RestoreFlow(vm, Mnemonic, "pw", "relock");
|
||||||
|
var path = Assert.Single(AppPaths.WalletFiles(NetKind.Mainnet));
|
||||||
|
Assert.Null(WalletLock.TryAcquire(path)); // held while open
|
||||||
|
|
||||||
|
vm.CloseWalletCommand.Execute(null);
|
||||||
|
|
||||||
|
using var l = WalletLock.TryAcquire(path); // released on close
|
||||||
|
Assert.NotNull(l);
|
||||||
|
Assert.False(vm.IsWalletOpen);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,361 @@
|
|||||||
|
using Avalonia.Threading;
|
||||||
|
using NBitcoin;
|
||||||
|
using PalladiumWallet.App.Localization;
|
||||||
|
using PalladiumWallet.App.ViewModels;
|
||||||
|
using PalladiumWallet.Core.Chain;
|
||||||
|
using PalladiumWallet.Core.Crypto;
|
||||||
|
using PalladiumWallet.Core.Net;
|
||||||
|
using PalladiumWallet.Core.Spv;
|
||||||
|
using PalladiumWallet.Core.Storage;
|
||||||
|
using PalladiumWallet.Core.Wallet;
|
||||||
|
using PalladiumWallet.Tests.Net;
|
||||||
|
|
||||||
|
namespace PalladiumWallet.Tests.App;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Headless tests of the single application ViewModel. Everything is sandboxed: the data
|
||||||
|
/// root is a temp folder, the saved server points at an in-process FakeElectrumServer (so the
|
||||||
|
/// auto-connect never reaches a real server) and the GitHub update check is stubbed.
|
||||||
|
/// </summary>
|
||||||
|
[Collection("AppPaths")]
|
||||||
|
public sealed class MainWindowViewModelTests : VmTestBase
|
||||||
|
{
|
||||||
|
// ---- startup, overlays, settings ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public Task Startup_ShowsTheStartStep_WithNoWalletOpen() => UiSession.Run(() =>
|
||||||
|
{
|
||||||
|
var vm = new MainWindowViewModel();
|
||||||
|
|
||||||
|
Assert.True(vm.IsStepStart);
|
||||||
|
Assert.False(vm.IsWalletOpen);
|
||||||
|
Assert.True(vm.IsSetupVisible);
|
||||||
|
Assert.StartsWith("Palladium Wallet", vm.WindowTitle);
|
||||||
|
Assert.True(vm.IsDesktop);
|
||||||
|
});
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public Task SelectTab_ChangesTheSelectedIndex() => UiSession.Run(() =>
|
||||||
|
{
|
||||||
|
var vm = new MainWindowViewModel();
|
||||||
|
vm.SelectTabCommand.Execute("3");
|
||||||
|
Assert.Equal(3, vm.SelectedTabIndex);
|
||||||
|
});
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public Task SettingsAndHelpOverlays_OpenAndClose() => UiSession.Run(() =>
|
||||||
|
{
|
||||||
|
var vm = new MainWindowViewModel();
|
||||||
|
vm.OpenSettingsCommand.Execute(null);
|
||||||
|
Assert.True(vm.IsSettingsOpen);
|
||||||
|
vm.CloseSettingsCommand.Execute(null);
|
||||||
|
Assert.False(vm.IsSettingsOpen);
|
||||||
|
vm.OpenHelpCommand.Execute(null);
|
||||||
|
Assert.True(vm.IsHelpOpen);
|
||||||
|
vm.CloseHelpCommand.Execute(null);
|
||||||
|
Assert.False(vm.IsHelpOpen);
|
||||||
|
});
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public Task SetLanguage_SwitchesTheLocInstance_AndPersistsIt() => UiSession.Run(() =>
|
||||||
|
{
|
||||||
|
var vm = new MainWindowViewModel();
|
||||||
|
var before = vm.Loc;
|
||||||
|
|
||||||
|
vm.SetLanguageCommand.Execute("it");
|
||||||
|
|
||||||
|
Assert.NotSame(before, vm.Loc); // new instance → bindings re-evaluate
|
||||||
|
Assert.Equal("it", vm.Loc.Language);
|
||||||
|
Assert.Equal("it", AppConfig.Load(AppPaths.ConfigPath()).Language);
|
||||||
|
});
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public Task AvailableUpdate_OpensTheUpdateOverlay() => UiSession.Run(async () =>
|
||||||
|
{
|
||||||
|
MainWindowViewModel.UpdateCheck = _ => Task.FromResult<LatestRelease?>(new LatestRelease("v9.9.9", "https://example.test/r"));
|
||||||
|
var vm = new MainWindowViewModel();
|
||||||
|
|
||||||
|
await Eventually(() => vm.IsUpdateAvailableOpen);
|
||||||
|
|
||||||
|
Assert.Equal("v9.9.9", vm.UpdateAvailableTag);
|
||||||
|
Assert.Equal("https://example.test/r", vm.UpdateReleaseUrl);
|
||||||
|
vm.CloseUpdateAvailableCommand.Execute(null);
|
||||||
|
Assert.False(vm.IsUpdateAvailableOpen);
|
||||||
|
});
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public Task NoUpdate_LeavesTheOverlayClosed() => UiSession.Run(async () =>
|
||||||
|
{
|
||||||
|
var vm = new MainWindowViewModel();
|
||||||
|
await Task.Delay(100);
|
||||||
|
Dispatcher.UIThread.RunJobs();
|
||||||
|
Assert.False(vm.IsUpdateAvailableOpen);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- wizard ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public Task Restore_WithInvalidMnemonic_StaysOnTheWordsStep_WithAnError() => UiSession.Run(() =>
|
||||||
|
{
|
||||||
|
var vm = new MainWindowViewModel();
|
||||||
|
vm.WizardStartRestoreCommand.Execute(null);
|
||||||
|
vm.MnemonicInput = "abandon abandon abandon";
|
||||||
|
|
||||||
|
vm.WizardNextFromWordsCommand.Execute(null);
|
||||||
|
|
||||||
|
Assert.True(vm.IsStepWords);
|
||||||
|
Assert.Equal(Loc.Tr("msg.words.invalid"), vm.StatusMessage);
|
||||||
|
});
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public Task NewWallet_ConfirmingTheWrongSeed_IsRejected() => UiSession.Run(() =>
|
||||||
|
{
|
||||||
|
var vm = new MainWindowViewModel();
|
||||||
|
vm.WizardStartNewCommand.Execute(null);
|
||||||
|
Assert.True(vm.IsStepShowSeed);
|
||||||
|
Assert.True(Bip39.TryParse(vm.MnemonicInput, out _));
|
||||||
|
vm.WizardNextFromShowSeedCommand.Execute(null);
|
||||||
|
vm.ConfirmMnemonicInput = Mnemonic; // not the displayed one
|
||||||
|
|
||||||
|
vm.WizardNextFromConfirmSeedCommand.Execute(null);
|
||||||
|
|
||||||
|
Assert.True(vm.IsStepConfirmSeed);
|
||||||
|
Assert.Equal(Loc.Tr("msg.seed.mismatch"), vm.StatusMessage);
|
||||||
|
});
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public Task NewWallet_ConfirmingTheRightSeed_AdvancesToThePassphraseStep() => UiSession.Run(() =>
|
||||||
|
{
|
||||||
|
var vm = new MainWindowViewModel();
|
||||||
|
vm.WizardStartNewCommand.Execute(null);
|
||||||
|
vm.WizardNextFromShowSeedCommand.Execute(null);
|
||||||
|
vm.ConfirmMnemonicInput = vm.MnemonicInput.ToUpperInvariant();
|
||||||
|
|
||||||
|
vm.WizardNextFromConfirmSeedCommand.Execute(null);
|
||||||
|
|
||||||
|
Assert.True(vm.IsStepPassphrase);
|
||||||
|
});
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public Task Password_Mismatch_OrEmpty_DoesNotCreateAnyFile() => UiSession.Run(() =>
|
||||||
|
{
|
||||||
|
var vm = new MainWindowViewModel();
|
||||||
|
vm.WizardStartRestoreCommand.Execute(null);
|
||||||
|
vm.MnemonicInput = Mnemonic;
|
||||||
|
vm.WizardNextFromWordsCommand.Execute(null);
|
||||||
|
vm.WizardNextFromPassphraseCommand.Execute(null);
|
||||||
|
vm.WizardNextFromScriptTypeCommand.Execute(null);
|
||||||
|
|
||||||
|
vm.PasswordInput = vm.ConfirmPasswordInput = "";
|
||||||
|
vm.CreateOrRestoreCommand.Execute(null);
|
||||||
|
Assert.Equal(Loc.Tr("msg.password.required"), vm.StatusMessage);
|
||||||
|
|
||||||
|
vm.PasswordInput = "one"; vm.ConfirmPasswordInput = "two";
|
||||||
|
vm.CreateOrRestoreCommand.Execute(null);
|
||||||
|
Assert.Equal(Loc.Tr("msg.password.mismatch"), vm.StatusMessage);
|
||||||
|
|
||||||
|
Assert.False(vm.IsWalletOpen);
|
||||||
|
Assert.Empty(AppPaths.WalletFiles(NetKind.Mainnet));
|
||||||
|
});
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public Task Restore_WithPassword_CreatesAnEncryptedWallet_AndOpensIt() => UiSession.Run(() =>
|
||||||
|
{
|
||||||
|
var vm = new MainWindowViewModel();
|
||||||
|
|
||||||
|
RestoreFlow(vm, Mnemonic, "s3cret", "main");
|
||||||
|
|
||||||
|
Assert.True(vm.IsWalletOpen);
|
||||||
|
var file = Assert.Single(AppPaths.WalletFiles(NetKind.Mainnet));
|
||||||
|
var content = File.ReadAllText(file);
|
||||||
|
Assert.True(EncryptedFile.IsEncrypted(content));
|
||||||
|
Assert.DoesNotContain("abandon", content); // seed never in plaintext on disk
|
||||||
|
Assert.Contains("NativeSegwit", vm.NetworkInfo);
|
||||||
|
});
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public Task Restore_WithoutEncryption_StoresPlainJson() => UiSession.Run(() =>
|
||||||
|
{
|
||||||
|
var vm = new MainWindowViewModel();
|
||||||
|
RestoreFlow(vm, Mnemonic, password: null, name: "plain");
|
||||||
|
|
||||||
|
Assert.True(vm.IsWalletOpen);
|
||||||
|
Assert.False(EncryptedFile.IsEncrypted(File.ReadAllText(Assert.Single(AppPaths.WalletFiles(NetKind.Mainnet)))));
|
||||||
|
});
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public Task Restore_OverAnExistingName_DoesNotOverwriteTheWallet() => UiSession.Run(() =>
|
||||||
|
{
|
||||||
|
var vm = new MainWindowViewModel();
|
||||||
|
RestoreFlow(vm, Mnemonic, "pw", "dup");
|
||||||
|
var path = Assert.Single(AppPaths.WalletFiles(NetKind.Mainnet));
|
||||||
|
var before = File.ReadAllBytes(path);
|
||||||
|
vm.CloseWalletCommand.Execute(null);
|
||||||
|
|
||||||
|
RestoreFlow(vm, Mnemonic, "other", "dup");
|
||||||
|
|
||||||
|
Assert.Equal(Loc.Tr("msg.wallet.exists"), vm.StatusMessage);
|
||||||
|
Assert.Equal(before, File.ReadAllBytes(path));
|
||||||
|
});
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public Task ImportAddress_CreatesAWatchOnlyWallet() => UiSession.Run(() =>
|
||||||
|
{
|
||||||
|
var vm = new MainWindowViewModel();
|
||||||
|
var address = HdAccount.FromMnemonic(ParseMnemonic(), null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet)
|
||||||
|
.GetReceiveAddress(0).ToString();
|
||||||
|
|
||||||
|
vm.WizardStartImportAddressCommand.Execute(null);
|
||||||
|
vm.ImportAddressInput = address;
|
||||||
|
vm.WizardNextFromImportAddressCommand.Execute(null);
|
||||||
|
vm.EncryptWallet = false;
|
||||||
|
vm.CreateOrRestoreCommand.Execute(null);
|
||||||
|
|
||||||
|
Assert.True(vm.IsWalletOpen);
|
||||||
|
Assert.True(vm.IsWatchOnlyAccount);
|
||||||
|
});
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public Task ImportXpub_CreatesAWatchOnlyWallet_ButANonsenseKeyIsRejected() => UiSession.Run(() =>
|
||||||
|
{
|
||||||
|
var vm = new MainWindowViewModel();
|
||||||
|
vm.WizardStartImportXkeyCommand.Execute(null);
|
||||||
|
vm.ImportXkeyInput = "not-an-extended-key";
|
||||||
|
vm.WizardNextFromImportXkeyCommand.Execute(null);
|
||||||
|
Assert.True(vm.IsStepImportXkey);
|
||||||
|
Assert.Equal(Loc.Tr("msg.xkey.invalid"), vm.StatusMessage);
|
||||||
|
|
||||||
|
vm.ImportXkeyInput = HdAccount.FromMnemonic(ParseMnemonic(), null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet).ToSlip132();
|
||||||
|
vm.WizardNextFromImportXkeyCommand.Execute(null);
|
||||||
|
vm.EncryptWallet = false;
|
||||||
|
vm.CreateOrRestoreCommand.Execute(null);
|
||||||
|
|
||||||
|
Assert.True(vm.IsWalletOpen);
|
||||||
|
Assert.True(vm.IsWatchOnlyAccount);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// ---- open existing ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public Task OpenExisting_WithWrongPassword_ShowsAnError_AndStaysClosed() => UiSession.Run(() =>
|
||||||
|
{
|
||||||
|
var vm = new MainWindowViewModel();
|
||||||
|
RestoreFlow(vm, Mnemonic, "right", "w1");
|
||||||
|
vm.CloseWalletCommand.Execute(null);
|
||||||
|
|
||||||
|
vm.WizardStartOpenCommand.Execute(null);
|
||||||
|
vm.PasswordInput = "wrong";
|
||||||
|
vm.OpenExistingCommand.Execute(null);
|
||||||
|
|
||||||
|
Assert.False(vm.IsWalletOpen);
|
||||||
|
Assert.Equal(Loc.Tr("msg.wrongpassword"), vm.StatusMessage);
|
||||||
|
});
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public Task OpenExisting_WithTheRightPassword_OpensTheWallet() => UiSession.Run(() =>
|
||||||
|
{
|
||||||
|
var vm = new MainWindowViewModel();
|
||||||
|
RestoreFlow(vm, Mnemonic, "right", "w1");
|
||||||
|
vm.CloseWalletCommand.Execute(null);
|
||||||
|
|
||||||
|
vm.WizardStartOpenCommand.Execute(null);
|
||||||
|
vm.PasswordInput = "right";
|
||||||
|
vm.OpenExistingCommand.Execute(null);
|
||||||
|
|
||||||
|
Assert.True(vm.IsWalletOpen);
|
||||||
|
});
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public Task OpenExisting_WhileAnotherInstanceHoldsTheLock_IsRefused() => UiSession.Run(() =>
|
||||||
|
{
|
||||||
|
var vm = new MainWindowViewModel();
|
||||||
|
RestoreFlow(vm, Mnemonic, "pw", "locked");
|
||||||
|
var path = Assert.Single(AppPaths.WalletFiles(NetKind.Mainnet));
|
||||||
|
// The first VM still holds the exclusive lock: a second instance must not open it.
|
||||||
|
var second = new MainWindowViewModel();
|
||||||
|
second.WizardStartOpenCommand.Execute(null);
|
||||||
|
second.PasswordInput = "pw";
|
||||||
|
second.OpenExistingCommand.Execute(null);
|
||||||
|
|
||||||
|
Assert.False(second.IsWalletOpen);
|
||||||
|
Assert.Equal(Loc.Tr("msg.wallet.locked"), second.StatusMessage);
|
||||||
|
Assert.True(File.Exists(path));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- contacts ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public Task Contacts_AreAddedPersistedAndRemoved() => UiSession.Run(() =>
|
||||||
|
{
|
||||||
|
var vm = new MainWindowViewModel();
|
||||||
|
RestoreFlow(vm, Mnemonic, null, "c");
|
||||||
|
|
||||||
|
vm.NewContactName = "Alice"; vm.NewContactAddress = "plm1qexample";
|
||||||
|
vm.AddContactCommand.Execute(null);
|
||||||
|
vm.NewContactName = ""; vm.NewContactAddress = "ignored";
|
||||||
|
vm.AddContactCommand.Execute(null); // blank name → ignored
|
||||||
|
|
||||||
|
Assert.Single(vm.Contacts);
|
||||||
|
var stored = WalletStore.Load(Assert.Single(AppPaths.WalletFiles(NetKind.Mainnet)));
|
||||||
|
Assert.Equal("Alice", Assert.Single(stored.Contacts).Name);
|
||||||
|
|
||||||
|
vm.SelectedContactInList = vm.Contacts[0];
|
||||||
|
vm.RemoveSelectedContactCommand.Execute(null);
|
||||||
|
Assert.Empty(vm.Contacts);
|
||||||
|
Assert.Empty(WalletStore.Load(Assert.Single(AppPaths.WalletFiles(NetKind.Mainnet))).Contacts);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- sync + send against the fake server ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public Task OpeningAFundedWallet_SyncsAgainstTheServer_AndShowsTheVerifiedHistory() => UiSession.Run(async () =>
|
||||||
|
{
|
||||||
|
var account = HdAccount.FromMnemonic(ParseMnemonic(), null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet);
|
||||||
|
WireFundedChain(account.GetReceiveAddress(0), 2_500_000);
|
||||||
|
var vm = new MainWindowViewModel();
|
||||||
|
|
||||||
|
RestoreFlow(vm, Mnemonic, null, "funded");
|
||||||
|
await Eventually(() => vm.IsConnected && !vm.IsSyncing && vm.History.Count == 1);
|
||||||
|
|
||||||
|
Assert.True(vm.History[0].Verified);
|
||||||
|
Assert.Contains("0.025", vm.BalanceText);
|
||||||
|
Assert.NotEmpty(vm.Addresses);
|
||||||
|
Assert.True(_server.CallCount("blockchain.transaction.get_merkle") >= 1); // Merkle proof was demanded
|
||||||
|
});
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public Task Send_PreparesASignedTransaction_ForAFundedWallet_AndRejectsABadAmount() => UiSession.Run(async () =>
|
||||||
|
{
|
||||||
|
var account = HdAccount.FromMnemonic(ParseMnemonic(), null, ScriptKind.NativeSegwit, ChainProfiles.Mainnet);
|
||||||
|
WireFundedChain(account.GetReceiveAddress(0), 50_000_000);
|
||||||
|
var vm = new MainWindowViewModel();
|
||||||
|
RestoreFlow(vm, Mnemonic, null, "sender");
|
||||||
|
await Eventually(() => !vm.IsSyncing && vm.History.Count == 1);
|
||||||
|
// Coins need MinConfirmations (tip 200, height 100 → 101 confirmations): spendable.
|
||||||
|
|
||||||
|
vm.SendTo = account.GetReceiveAddress(7).ToString();
|
||||||
|
vm.SendAmount = "not-a-number";
|
||||||
|
await vm.PrepareSendCommand.ExecuteAsync(null);
|
||||||
|
Assert.Equal(Loc.Tr("msg.amount.invalid"), vm.SendPreview);
|
||||||
|
Assert.False(vm.HasPendingSend);
|
||||||
|
|
||||||
|
vm.SendAmount = "0.1";
|
||||||
|
await vm.PrepareSendCommand.ExecuteAsync(null);
|
||||||
|
Assert.True(vm.HasPendingSend, vm.SendPreview);
|
||||||
|
Assert.Contains("txid", vm.SendPreview);
|
||||||
|
|
||||||
|
vm.SendAmount = "999";
|
||||||
|
await vm.PrepareSendCommand.ExecuteAsync(null);
|
||||||
|
Assert.False(vm.HasPendingSend);
|
||||||
|
Assert.Contains(Loc.Tr("msg.error"), vm.SendPreview);
|
||||||
|
});
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public Task Send_BeforeAnySync_AsksToSyncFirst() => UiSession.Run(async () =>
|
||||||
|
{
|
||||||
|
var vm = new MainWindowViewModel();
|
||||||
|
await vm.PrepareSendCommand.ExecuteAsync(null);
|
||||||
|
Assert.Equal(Loc.Tr("msg.send.sync.first"), vm.SendPreview);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
using Avalonia.Threading;
|
||||||
|
using NBitcoin;
|
||||||
|
using PalladiumWallet.App.Localization;
|
||||||
|
using PalladiumWallet.App.ViewModels;
|
||||||
|
using PalladiumWallet.Core.Chain;
|
||||||
|
using PalladiumWallet.Core.Crypto;
|
||||||
|
using PalladiumWallet.Core.Net;
|
||||||
|
using PalladiumWallet.Core.Spv;
|
||||||
|
using PalladiumWallet.Core.Storage;
|
||||||
|
using PalladiumWallet.Core.Wallet;
|
||||||
|
using PalladiumWallet.Tests.Net;
|
||||||
|
|
||||||
|
namespace PalladiumWallet.Tests.App;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Headless tests of the single application ViewModel. Everything is sandboxed: the data
|
||||||
|
/// root is a temp folder, the saved server points at an in-process FakeElectrumServer (so the
|
||||||
|
/// auto-connect never reaches a real server) and the GitHub update check is stubbed.
|
||||||
|
/// </summary>
|
||||||
|
[Collection("AppPaths")] // static AppPaths / Loc / update-check seam
|
||||||
|
public abstract class VmTestBase : IDisposable
|
||||||
|
{
|
||||||
|
protected const string Mnemonic =
|
||||||
|
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
|
||||||
|
|
||||||
|
private readonly string _root = Path.Combine(Path.GetTempPath(), $"plm-vm-{Guid.NewGuid()}");
|
||||||
|
private readonly string? _savedOverride = AppPaths.OverrideDataRoot;
|
||||||
|
private readonly Func<string, Task<LatestRelease?>> _savedUpdateCheck = MainWindowViewModel.UpdateCheck;
|
||||||
|
protected readonly FakeElectrumServer _server = new();
|
||||||
|
|
||||||
|
protected VmTestBase()
|
||||||
|
{
|
||||||
|
AppPaths.OverrideDataRoot = _root;
|
||||||
|
MainWindowViewModel.UpdateCheck = _ => Task.FromResult<LatestRelease?>(null);
|
||||||
|
// No bootstrap servers: the only reachable server is the in-process fake, so the
|
||||||
|
// VM's connect/reconnect loops can never touch a real indexing server.
|
||||||
|
MainWindowViewModel.ProfileOverride = ChainProfiles.Mainnet with { BootstrapServers = [] };
|
||||||
|
new AppConfig { LastServerHost = _server.Host, LastServerPort = _server.Port, LastServerUseSsl = false }
|
||||||
|
.Save(AppPaths.ConfigPath());
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
MainWindowViewModel.UpdateCheck = _savedUpdateCheck;
|
||||||
|
// ProfileOverride is intentionally left in place: ViewModels created by a test keep their
|
||||||
|
// keep-alive timers running, and restoring the real profile would let them reconnect to
|
||||||
|
// the real bootstrap servers after the test is over. Nothing else in the suite uses it.
|
||||||
|
AppPaths.OverrideDataRoot = _savedOverride;
|
||||||
|
Loc.SwitchTo("en");
|
||||||
|
_server.DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||||
|
try { Directory.Delete(_root, recursive: true); } catch { /* best effort */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static async Task Eventually(Func<bool> condition, int timeoutMs = 10_000)
|
||||||
|
{
|
||||||
|
for (var waited = 0; !condition(); waited += 20)
|
||||||
|
{
|
||||||
|
Assert.True(waited < timeoutMs, "condition not reached in time");
|
||||||
|
Dispatcher.UIThread.RunJobs();
|
||||||
|
await Task.Delay(20);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static void RestoreFlow(MainWindowViewModel vm, string mnemonic, string? password, string? name = null)
|
||||||
|
{
|
||||||
|
vm.WizardStartRestoreCommand.Execute(null);
|
||||||
|
vm.MnemonicInput = mnemonic;
|
||||||
|
vm.WizardNextFromWordsCommand.Execute(null);
|
||||||
|
vm.WizardNextFromPassphraseCommand.Execute(null);
|
||||||
|
vm.WizardNextFromScriptTypeCommand.Execute(null);
|
||||||
|
vm.EncryptWallet = password is not null;
|
||||||
|
vm.PasswordInput = vm.ConfirmPasswordInput = password ?? "";
|
||||||
|
vm.WalletNameInput = name ?? "";
|
||||||
|
vm.CreateOrRestoreCommand.Execute(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static Mnemonic ParseMnemonic()
|
||||||
|
{
|
||||||
|
Assert.True(Bip39.TryParse(Mnemonic, out var m));
|
||||||
|
return m!;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected (Transaction Funding, string TxHex) WireFundedChain(BitcoinAddress to, long sats)
|
||||||
|
{
|
||||||
|
var net = PalladiumNetworks.Mainnet;
|
||||||
|
var tx = net.CreateTransaction();
|
||||||
|
tx.Inputs.Add(new TxIn(new OutPoint(uint256.One, 0)));
|
||||||
|
tx.Outputs.Add(Money.Satoshis(sats), to);
|
||||||
|
var txid = tx.GetHash();
|
||||||
|
var scripthash = Scripthash.FromAddress(to);
|
||||||
|
|
||||||
|
string Header(uint256 root, int h)
|
||||||
|
{
|
||||||
|
var header = net.Consensus.ConsensusFactory.CreateBlockHeader();
|
||||||
|
header.HashPrevBlock = uint256.Zero;
|
||||||
|
header.HashMerkleRoot = root;
|
||||||
|
header.BlockTime = DateTimeOffset.FromUnixTimeSeconds(1_700_000_000 + h);
|
||||||
|
header.Bits = new Target(0x1d00ffffu);
|
||||||
|
header.Nonce = (uint)h;
|
||||||
|
return Convert.ToHexString(header.ToBytes()).ToLowerInvariant();
|
||||||
|
}
|
||||||
|
|
||||||
|
_server.Handle("blockchain.headers.subscribe", _ => new { height = 200, hex = Header(uint256.One, 200) });
|
||||||
|
_server.Handle("blockchain.scripthash.subscribe", _ => null);
|
||||||
|
_server.Handle("blockchain.scripthash.get_history", p =>
|
||||||
|
p[0].GetString() == scripthash ? new[] { new { tx_hash = txid.ToString(), height = 100 } } : Array.Empty<object>());
|
||||||
|
_server.Handle("blockchain.transaction.get", _ => tx.ToHex());
|
||||||
|
_server.Handle("blockchain.transaction.get_merkle",
|
||||||
|
_ => new { block_height = 100, pos = 0, merkle = Array.Empty<string>() });
|
||||||
|
_server.Handle("blockchain.block.header", _ => Header(txid, 100));
|
||||||
|
_server.Handle("blockchain.block.headers", _ => new { count = 0, hex = "" });
|
||||||
|
return (tx, tx.ToHex());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ namespace PalladiumWallet.Tests.Net;
|
|||||||
///
|
///
|
||||||
/// Example: PALLADIUM_TEST_SERVERS=1.2.3.4:50002 PALLADIUM_TEST_SSL=1 dotnet test --filter LiveServer
|
/// Example: PALLADIUM_TEST_SERVERS=1.2.3.4:50002 PALLADIUM_TEST_SSL=1 dotnet test --filter LiveServer
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Collection("Console")] // the probe writes to Console, which is process-global
|
[Collection("AppPaths")] // serialized with other tests touching process-wide state (Console, AppPaths)
|
||||||
public class LiveServerTests
|
public class LiveServerTests
|
||||||
{
|
{
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Avalonia.Headless" Version="12.0.4" />
|
||||||
<PackageReference Include="coverlet.collector" Version="6.0.0" />
|
<PackageReference Include="coverlet.collector" Version="6.0.0" />
|
||||||
<PackageReference Include="CsCheck" Version="4.7.0" />
|
<PackageReference Include="CsCheck" Version="4.7.0" />
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
|
||||||
@@ -25,6 +26,8 @@
|
|||||||
<ProjectReference Include="..\..\src\Core\PalladiumWallet.Core.csproj" />
|
<ProjectReference Include="..\..\src\Core\PalladiumWallet.Core.csproj" />
|
||||||
<ProjectReference Include="..\PalladiumWallet.Fuzz\PalladiumWallet.Fuzz.csproj" />
|
<ProjectReference Include="..\PalladiumWallet.Fuzz\PalladiumWallet.Fuzz.csproj" />
|
||||||
<ProjectReference Include="..\PalladiumWallet.ServerProbe\PalladiumWallet.ServerProbe.csproj" />
|
<ProjectReference Include="..\PalladiumWallet.ServerProbe\PalladiumWallet.ServerProbe.csproj" />
|
||||||
|
<ProjectReference Include="..\..\src\App\PalladiumWallet.App.csproj" />
|
||||||
|
<ProjectReference Include="..\..\src\Cli\PalladiumWallet.Cli.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
Reference in new issue
Block a user