From 42e8dfdb63d5f8fcf9e13a7695b6cccf4b725a6d Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Tue, 3 Nov 2026 09:34:34 +0100 Subject: [PATCH] test: add ServerProbe tool and opt-in live server test ServerProbe is a read-only conformance/health checker for Electrum protocol servers (Fulcrum, ElectrumX): handshake/TLS, features and genesis, header linkage, Merkle proof vs header root, fees, latency. LiveServerTests runs it from dotnet test when PALLADIUM_TEST_SERVERS is set. Also ignore .claude/, apk/aab and local.properties. --- .gitignore | 6 + AGENTS.md | 1 + CLAUDE.md | 1 + PalladiumWallet.sln | 15 + .../PalladiumWallet.ServerProbe.csproj | 15 + tests/PalladiumWallet.ServerProbe/Program.cs | 292 ++++++++++++++++++ .../Net/LiveServerTests.cs | 45 +++ .../PalladiumWallet.Tests.csproj | 3 +- 8 files changed, 377 insertions(+), 1 deletion(-) create mode 100644 tests/PalladiumWallet.ServerProbe/PalladiumWallet.ServerProbe.csproj create mode 100644 tests/PalladiumWallet.ServerProbe/Program.cs create mode 100644 tests/PalladiumWallet.Tests/Net/LiveServerTests.cs diff --git a/.gitignore b/.gitignore index ef9101a..19a2b91 100644 --- a/.gitignore +++ b/.gitignore @@ -93,6 +93,12 @@ settings.local.json # Local agent/tooling metadata .agents/ .codex/ +.claude/ + +# Android packages and local SDK config +*.apk +*.aab +local.properties # Fuzzing artifacts (tests/PalladiumWallet.Fuzz) findings/ diff --git a/AGENTS.md b/AGENTS.md index 5712dec..4498726 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,6 +53,7 @@ docker/ reproducible release builds (build.sh + pinned Dockerfiles) - Test tree mirrors `src/Core` (`Chain/ Crypto/ Net/ Spv/ Storage/ Wallet/`) — put new tests in the folder matching the code under test - Network/SPV code (`ElectrumClient`, `WalletSynchronizer`, `TransactionInspector`, TOFU pinning): test against the in-process fake server `tests/PalladiumWallet.Tests/Net/FakeElectrumServer.cs` (loopback TCP + optional TLS, per-method handlers, call counters) — extend that, don't mock the client (it isn't an interface, by design) - Two `internal` test seams (via `InternalsVisibleTo`) sandbox the remaining externals: `UpdateChecker.CheckAsync` takes an optional `HttpMessageHandler` (never hit GitHub from a test); `AppPaths.BootstrapDirOverride`/`PortableBaseOverride`/`DefaultRootOverride` redirect the machine-global path locations (tests using them share xUnit collection `"AppPaths"` because that state is static) +- **Live server probe** (`tests/PalladiumWallet.ServerProbe`): read-only conformance/health check for real Electrum/Fulcrum/ElectrumX servers (`dotnet run --project tests/PalladiumWallet.ServerProbe -- --server host:port --ssl`, or `--registry`). Also runnable from `dotnet test` via env vars: `PALLADIUM_TEST_SERVERS=host:port,… [PALLADIUM_TEST_SSL=1] [PALLADIUM_TEST_NET=…] dotnet test --filter LiveServer` (no-op when unset, so the default run stays offline) - **Fuzzing** (`tests/PalladiumWallet.Fuzz`, SharpFuzz): one target per untrusted-input parser (`header merkle slip132 bip39 address coinamount walletdoc encfile peers`), each encoding that parser's error contract — any other escaping exception is a finding - Seed corpus (incl. regression inputs for past findings) replays inside `dotnet test` via `FuzzCorpusTests` - Quick smoke without tooling: `dotnet run --project tests/PalladiumWallet.Fuzz -- --random 50000` diff --git a/CLAUDE.md b/CLAUDE.md index 9d28a05..cc58f3d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,6 +53,7 @@ docker/ reproducible release builds (build.sh + pinned Dockerfiles) - Test tree mirrors `src/Core` (`Chain/ Crypto/ Net/ Spv/ Storage/ Wallet/`) — put new tests in the folder matching the code under test - Network/SPV code (`ElectrumClient`, `WalletSynchronizer`, `TransactionInspector`, TOFU pinning): test against the in-process fake server `tests/PalladiumWallet.Tests/Net/FakeElectrumServer.cs` (loopback TCP + optional TLS, per-method handlers, call counters) — extend that, don't mock the client (it isn't an interface, by design) - Two `internal` test seams (via `InternalsVisibleTo`) sandbox the remaining externals: `UpdateChecker.CheckAsync` takes an optional `HttpMessageHandler` (never hit GitHub from a test); `AppPaths.BootstrapDirOverride`/`PortableBaseOverride`/`DefaultRootOverride` redirect the machine-global path locations (tests using them share xUnit collection `"AppPaths"` because that state is static) +- **Live server probe** (`tests/PalladiumWallet.ServerProbe`): read-only conformance/health check for real Electrum/Fulcrum/ElectrumX servers (`dotnet run --project tests/PalladiumWallet.ServerProbe -- --server host:port --ssl`, or `--registry`). Also runnable from `dotnet test` via env vars: `PALLADIUM_TEST_SERVERS=host:port,… [PALLADIUM_TEST_SSL=1] [PALLADIUM_TEST_NET=…] dotnet test --filter LiveServer` (no-op when unset, so the default run stays offline) - **Fuzzing** (`tests/PalladiumWallet.Fuzz`, SharpFuzz): one target per untrusted-input parser (`header merkle slip132 bip39 address coinamount walletdoc encfile peers`), each encoding that parser's error contract — any other escaping exception is a finding - Seed corpus (incl. regression inputs for past findings) replays inside `dotnet test` via `FuzzCorpusTests` - Quick smoke without tooling: `dotnet run --project tests/PalladiumWallet.Fuzz -- --random 50000` diff --git a/PalladiumWallet.sln b/PalladiumWallet.sln index 542ccaa..2efa049 100644 --- a/PalladiumWallet.sln +++ b/PalladiumWallet.sln @@ -23,6 +23,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PalladiumWallet.Fuzz", "tes EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Core", "Core", "{BF9B33E3-314A-3621-C1F0-AFD074692421}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PalladiumWallet.ServerProbe", "tests\PalladiumWallet.ServerProbe\PalladiumWallet.ServerProbe.csproj", "{4CB51620-11B7-47E9-9971-A999E49D1CE1}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -117,6 +119,18 @@ Global {BC5932E5-E6F7-42A2-AC39-697C9A737A3C}.Release|x64.Build.0 = Release|Any CPU {BC5932E5-E6F7-42A2-AC39-697C9A737A3C}.Release|x86.ActiveCfg = Release|Any CPU {BC5932E5-E6F7-42A2-AC39-697C9A737A3C}.Release|x86.Build.0 = Release|Any CPU + {4CB51620-11B7-47E9-9971-A999E49D1CE1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4CB51620-11B7-47E9-9971-A999E49D1CE1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4CB51620-11B7-47E9-9971-A999E49D1CE1}.Debug|x64.ActiveCfg = Debug|Any CPU + {4CB51620-11B7-47E9-9971-A999E49D1CE1}.Debug|x64.Build.0 = Debug|Any CPU + {4CB51620-11B7-47E9-9971-A999E49D1CE1}.Debug|x86.ActiveCfg = Debug|Any CPU + {4CB51620-11B7-47E9-9971-A999E49D1CE1}.Debug|x86.Build.0 = Debug|Any CPU + {4CB51620-11B7-47E9-9971-A999E49D1CE1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4CB51620-11B7-47E9-9971-A999E49D1CE1}.Release|Any CPU.Build.0 = Release|Any CPU + {4CB51620-11B7-47E9-9971-A999E49D1CE1}.Release|x64.ActiveCfg = Release|Any CPU + {4CB51620-11B7-47E9-9971-A999E49D1CE1}.Release|x64.Build.0 = Release|Any CPU + {4CB51620-11B7-47E9-9971-A999E49D1CE1}.Release|x86.ActiveCfg = Release|Any CPU + {4CB51620-11B7-47E9-9971-A999E49D1CE1}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -130,5 +144,6 @@ Global {BCC5BE4A-B909-4043-B0FB-B5A839349578} = {84E60614-5042-48EC-B349-290FB0CA7BA8} {BC5932E5-E6F7-42A2-AC39-697C9A737A3C} = {FDF1822C-58D6-4B35-93EA-6A85E1292933} {BF9B33E3-314A-3621-C1F0-AFD074692421} = {84E60614-5042-48EC-B349-290FB0CA7BA8} + {4CB51620-11B7-47E9-9971-A999E49D1CE1} = {FDF1822C-58D6-4B35-93EA-6A85E1292933} EndGlobalSection EndGlobal diff --git a/tests/PalladiumWallet.ServerProbe/PalladiumWallet.ServerProbe.csproj b/tests/PalladiumWallet.ServerProbe/PalladiumWallet.ServerProbe.csproj new file mode 100644 index 0000000..c4ce8ac --- /dev/null +++ b/tests/PalladiumWallet.ServerProbe/PalladiumWallet.ServerProbe.csproj @@ -0,0 +1,15 @@ + + + + Exe + net10.0 + enable + enable + false + + + + + + + diff --git a/tests/PalladiumWallet.ServerProbe/Program.cs b/tests/PalladiumWallet.ServerProbe/Program.cs new file mode 100644 index 0000000..08b894e --- /dev/null +++ b/tests/PalladiumWallet.ServerProbe/Program.cs @@ -0,0 +1,292 @@ +// Black-box conformance/health probe for Electrum-protocol servers (Fulcrum, ElectrumX, electrs). +// Reuses the wallet's own ElectrumClient, so a server that passes here is one the wallet can use. +// Read-only: never sends transactions and never touches wallet files. +using System.Diagnostics; +using System.Net.Security; +using System.Text.Json; +using NBitcoin; +using PalladiumWallet.Core.Chain; +using PalladiumWallet.Core.Net; +using PalladiumWallet.Core.Spv; + +if (args.Length == 0 || args.Contains("--help") || args.Contains("-h")) return Usage(); + +var profile = Opt("--net") switch +{ + "testnet" => ChainProfiles.Testnet, + "regtest" => ChainProfiles.Regtest, + _ => ChainProfiles.Mainnet, +}; +var useSsl = args.Contains("--ssl"); +var timeout = TimeSpan.FromSeconds(int.TryParse(Opt("--timeout"), out var t) ? t : 15); +var pings = int.TryParse(Opt("--pings"), out var p) ? p : 5; +var maxLag = int.TryParse(Opt("--max-lag"), out var l) ? l : 2; + +var targets = new List<(string Host, int Port, bool Ssl)>(); +for (var i = 0; i < args.Length - 1; i++) + if (args[i] == "--server") targets.Add(ParseTarget(args[i + 1])); +if (Opt("--file") is { } file) + foreach (var line in File.ReadAllLines(file)) + if (line.Trim() is { Length: > 0 } s && !s.StartsWith('#')) targets.Add(ParseTarget(s)); +if (args.Contains("--registry")) + foreach (var s in new ServerRegistry(profile, Path.GetTempFileName()).All) + targets.Add((s.Host, s.PortFor(useSsl), useSsl)); +if (targets.Count == 0) { Console.Error.WriteLine("No server given."); return Usage(); } + +var failed = 0; +var heights = new List<(string Name, int Height)>(); +foreach (var target in targets) +{ + Console.WriteLine($"\n=== {target.Host}:{target.Port}{(target.Ssl ? " (TLS)" : "")} [{profile.NetName}] ==="); + var (ok, height) = await Probe(target); + if (!ok) failed++; + if (height is { } h) heights.Add(($"{target.Host}:{target.Port}", h)); +} + +if (heights.Count > 1) +{ + var best = heights.Max(x => x.Height); + Console.WriteLine($"\n=== Cross-server tip (best {best}) ==="); + foreach (var (name, h) in heights) + Console.WriteLine($" {(best - h > maxLag ? "WARN" : " ok ")} {name}: {h} (lag {best - h})"); +} +Console.WriteLine($"\n{targets.Count - failed}/{targets.Count} server(s) passed."); +return failed == 0 ? 0 : 1; + +// --------------------------------------------------------------------------- + +async Task<(bool Ok, int? Height)> Probe((string Host, int Port, bool Ssl) s) +{ + var allOk = true; + int? tip = null; + + async Task Step(string name, Func> body) + { + using var cts = new CancellationTokenSource(timeout); + var sw = Stopwatch.StartNew(); + try + { + var (value, detail, warn) = await body(cts.Token); + Console.WriteLine($" {(warn ? "WARN" : " ok ")} {name,-22} {sw.ElapsedMilliseconds,5} ms {detail}"); + return value; + } + catch (Exception ex) + { + allOk = false; + var msg = ex is OperationCanceledException ? $"timeout after {timeout.TotalSeconds:0}s" : ex.Message; + Console.WriteLine($" FAIL {name,-22} {sw.ElapsedMilliseconds,5} ms {msg}"); + return default; + } + } + + ElectrumClient? client = null; + try + { + client = await Step("connect+handshake", async ct => + { + var c = await ElectrumClient.ConnectAsync(s.Host, s.Port, s.Ssl, pins: null, ct); + return (c, s.Ssl ? "TLS fingerprint " + await Fingerprint(s.Host, s.Port, ct) : "plaintext", !s.Ssl); + }); + if (client is null) return (false, null); + var c = client; + await Step("server.banner", async ct => + { + var r = await c.RequestAsync("server.banner", ct); + var b = (r.GetString() ?? "").Replace('\n', ' ').Trim(); + return (0, b.Length > 60 ? b[..60] + "…" : b, false); + }); + + await Step("server.features", async ct => + { + var r = await c.RequestAsync("server.features", ct); + var genesis = r.TryGetProperty("genesis_hash", out var g) ? g.GetString() : null; + var hashFn = r.TryGetProperty("hash_function", out var hf) ? hf.GetString() : null; + if (genesis != profile.GenesisHash) + throw new Exception($"genesis_hash {genesis} != expected {profile.GenesisHash} (wrong chain)"); + if (hashFn != "sha256") throw new Exception($"hash_function '{hashFn}' (expected sha256)"); + var pruning = r.TryGetProperty("pruning", out var pr) && pr.ValueKind == JsonValueKind.Number; + return (0, $"genesis ok, pruning={(pruning ? pr.ToString() : "none")}", pruning); + }); + + var tipInfo = await Step("headers.subscribe", async ct => + { + var r = await c.RequestAsync("blockchain.headers.subscribe", ct); + var height = r.GetProperty("height").GetInt32(); + var header = BlockHeaderInfo.Parse(r.GetProperty("hex").GetString()!); + var age = DateTimeOffset.UtcNow - DateTimeOffset.FromUnixTimeSeconds(header.Timestamp); + var stale = age.TotalSeconds > profile.BlockTimeSeconds * 30; + return ((height, header), $"height {height}, tip age {FormatAge(age)}", stale); + }); + if (tipInfo.header is null) return (false, null); + tip = tipInfo.height; + var (tipHeight, tipHeader) = tipInfo; + + await Step("genesis header", async ct => + { + var hex = (await c.RequestAsync("blockchain.block.header", ct, 0)).GetString()!; + var h = BlockHeaderInfo.Parse(hex); + if (h.Hash.ToString() != profile.GenesisHash) throw new Exception($"header 0 hash {h.Hash}"); + return (0, "hash matches profile", false); + }); + + await Step("header chain linkage", async ct => + { + var count = Math.Min(20, tipHeight); + var start = tipHeight - count + 1; + var r = await c.RequestAsync("blockchain.block.headers", ct, start, count); + var raw = Convert.FromHexString(r.GetProperty("hex").GetString()!); + var n = raw.Length / BlockHeaderInfo.Size; + if (n == 0) throw new Exception("empty header batch"); + BlockHeaderInfo? prev = null; + for (var i = 0; i < n; i++) + { + var h = BlockHeaderInfo.Parse(raw[(i * 80)..((i + 1) * 80)]); + if (prev is not null && h.PrevHash != prev.Hash) throw new Exception($"broken link at height {start + i}"); + prev = h; + } + // The batch may be a block or two ahead of the earlier subscribe; only compare when equal. + var note = start + n - 1 == tipHeight && prev!.Hash != tipHeader.Hash + ? throw new Exception("batch tip differs from headers.subscribe tip") + : ""; + return (0, $"{n} headers linked {note}".Trim(), false); + }); + + await Step("merkle proof (SPV)", async ct => + { + // A block a few confirmations deep: its coinbase must verify against the header root. + var height = Math.Max(1, tipHeight - 6); + var hdr = BlockHeaderInfo.Parse((await c.RequestAsync("blockchain.block.header", ct, height)).GetString()!); + var r = await c.RequestAsync("blockchain.transaction.id_from_pos", ct, height, 0, true); + if (r.ValueKind != JsonValueKind.Object || !(r.TryGetProperty("tx_hash", out _) || r.TryGetProperty("tx_id", out _)) || !r.TryGetProperty("merkle", out _)) throw new Exception("unexpected id_from_pos reply: " + r.ToString()[..Math.Min(120, r.ToString().Length)]); + var txid = uint256.Parse((r.TryGetProperty("tx_hash", out var th) ? th : r.GetProperty("tx_id")).GetString()!); + var branch = r.GetProperty("merkle").EnumerateArray().Select(e => uint256.Parse(e.GetString()!)).ToList(); + if (!MerkleProof.Verify(txid, 0, branch, hdr.MerkleRoot)) + throw new Exception($"merkle proof for coinbase at height {height} does NOT match header root"); + return (0, $"coinbase {txid.ToString()[..12]}… @ {height} verified", false); + }); + + await Step("estimatefee/relayfee", async ct => + { + var est = (await c.RequestAsync("blockchain.estimatefee", ct, 2)).GetDouble(); + var relay = (await c.RequestAsync("blockchain.relayfee", ct)).GetDouble(); + return (0, $"est(2 blk)={(est < 0 ? "n/a" : est.ToString("0.########"))} PLM/kB, relay={relay:0.########}", relay <= 0); + }); + + await Step("fee histogram", async ct => + { + var r = await c.RequestAsync("mempool.get_fee_histogram", ct); + return (0, $"{r.GetArrayLength()} buckets", false); + }); + + await Step("peers.subscribe", async ct => + { + var r = await c.RequestAsync("server.peers.subscribe", ct); + return (0, $"{r.GetArrayLength()} peers", false); + }); + + await Step("unknown scripthash", async ct => + { + // Well-formed but unused scripthash must return an empty, not an error. + var sh = new string('0', 64); + var r = await c.RequestAsync("blockchain.scripthash.get_balance", ct, sh); + var conf = r.GetProperty("confirmed").GetInt64(); + if (conf != 0) throw new Exception($"confirmed={conf} for an all-zero scripthash"); + return (0, "balance 0/0", false); + }); + + await Step("error handling", async ct => + { + try { await c.RequestAsync("blockchain.block.header", ct, int.MaxValue); } + catch (ElectrumServerException) { return (0, "bad height → JSON-RPC error", false); } + throw new Exception("server answered a nonexistent height instead of an error"); + }); + + await Step($"latency x{pings}", async ct => + { + var times = new List(); + for (var i = 0; i < pings; i++) + { + var sw = Stopwatch.StartNew(); + await c.RequestAsync("server.ping", ct); + times.Add(sw.Elapsed.TotalMilliseconds); + } + return (0, $"min {times.Min():0} / avg {times.Average():0} / max {times.Max():0} ms", times.Average() > 1500); + }); + + await Step("concurrent burst x50", async ct => + { + var sw = Stopwatch.StartNew(); + var rs = await Task.WhenAll(Enumerable.Range(0, 50) + .Select(i => c.RequestAsync("blockchain.block.header", ct, Math.Max(0, tipHeight - i)))); + if (rs.Any(r => r.ValueKind != JsonValueKind.String)) throw new Exception("malformed reply in burst"); + return (0, $"50 pipelined requests in {sw.ElapsedMilliseconds} ms", false); + }); + + await Step("tip still advancing", async ct => + { + var r = await c.RequestAsync("blockchain.headers.subscribe", ct); + var now = r.GetProperty("height").GetInt32(); + tip = now; + return (0, $"height {now} (was {tipHeight})", now < tipHeight); + }); + } + finally + { + if (client is not null) await client.DisposeAsync(); + } + Console.WriteLine(allOk ? " => PASS" : " => FAIL"); + return (allOk, tip); +} + +static async Task Fingerprint(string host, int port, CancellationToken ct) +{ + using var tcp = new System.Net.Sockets.TcpClient(); + await tcp.ConnectAsync(host, port, ct); + string fp = "?"; + await using var ssl = new SslStream(tcp.GetStream(), false, (_, cert, _, _) => + { + if (cert is not null) fp = CertificatePinStore.Fingerprint(cert); + return true; + }); + await ssl.AuthenticateAsClientAsync(new SslClientAuthenticationOptions { TargetHost = host }, ct); + return fp.Length > 23 ? fp[..23] + "…" : fp; +} + +static string FormatAge(TimeSpan a) => + a.TotalMinutes < 120 ? $"{a.TotalMinutes:0} min" : a.TotalHours < 48 ? $"{a.TotalHours:0.#} h" : $"{a.TotalDays:0.#} d"; + +(string Host, int Port, bool Ssl) ParseTarget(string spec) +{ + var parts = spec.Trim().Split(':'); + var port = parts.Length > 1 ? int.Parse(parts[1]) : useSsl ? profile.DefaultSslPort : profile.DefaultTcpPort; + return (parts[0], port, useSsl); +} + +string? Opt(string name) +{ + var i = Array.IndexOf(args, name); + return i >= 0 && i + 1 < args.Length ? args[i + 1] : null; +} + +static int Usage() +{ + Console.WriteLine(""" + PalladiumWallet server probe — conformance/health checks for Electrum-protocol servers + + usage: dotnet run --project tests/PalladiumWallet.ServerProbe -- [options] + + --server host[:port] server to test (repeatable) + --file PATH text file, one host[:port] per line ('#' comments) + --registry test every server known for the network + --ssl use TLS (default port 50002; plain 50001) + --net mainnet|testnet|regtest (default mainnet) + --timeout SEC per-check timeout (default 15) + --pings N latency samples (default 5) + --max-lag N blocks behind the best server before WARN (default 2) + + Checks: handshake/TLS, version, banner, features (genesis + sha256), tip freshness, + genesis header, header linkage, Merkle proof vs header root, fees, peers, error + handling, latency, pipelined burst. Exit code 1 if any server has a FAIL. + """); + return 2; +} diff --git a/tests/PalladiumWallet.Tests/Net/LiveServerTests.cs b/tests/PalladiumWallet.Tests/Net/LiveServerTests.cs new file mode 100644 index 0000000..2b6c0ed --- /dev/null +++ b/tests/PalladiumWallet.Tests/Net/LiveServerTests.cs @@ -0,0 +1,45 @@ +using System.Reflection; + +namespace PalladiumWallet.Tests.Net; + +/// +/// Opt-in conformance run of the ServerProbe against real Electrum/Fulcrum servers. +/// Does nothing (passes) unless PALLADIUM_TEST_SERVERS is set, so the default +/// dotnet test stays offline and deterministic. +/// +/// PALLADIUM_TEST_SERVERS=host1:50002,host2:50002 servers to probe (comma separated) +/// PALLADIUM_TEST_SSL=1 use TLS +/// PALLADIUM_TEST_NET=mainnet|testnet|regtest network (default mainnet) +/// +/// Example: PALLADIUM_TEST_SERVERS=1.2.3.4:50002 PALLADIUM_TEST_SSL=1 dotnet test --filter LiveServer +/// +[Collection("Console")] // the probe writes to Console, which is process-global +public class LiveServerTests +{ + [Fact] + public async Task LiveServer_PassesProbe() + { + var servers = Environment.GetEnvironmentVariable("PALLADIUM_TEST_SERVERS"); + if (string.IsNullOrWhiteSpace(servers)) return; + + var args = new List(); + foreach (var s in servers.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + args.AddRange(["--server", s]); + if (Environment.GetEnvironmentVariable("PALLADIUM_TEST_SSL") is "1" or "true") args.Add("--ssl"); + if (Environment.GetEnvironmentVariable("PALLADIUM_TEST_NET") is { Length: > 0 } net) args.AddRange(["--net", net]); + + // The probe is a top-level-statements exe: call its entry point rather than shelling out. + var entry = Assembly.Load("PalladiumWallet.ServerProbe").EntryPoint + ?? throw new InvalidOperationException("ServerProbe has no entry point."); + + var original = Console.Out; + var captured = new StringWriter(); + Console.SetOut(captured); + int exit; + // The synthesized entry point is a synchronous Main(string[]) → int wrapper. + try { exit = await Task.Run(() => (int)entry.Invoke(null, [args.ToArray()])!); } + finally { Console.SetOut(original); } + + Assert.True(exit == 0, captured.ToString()); + } +} diff --git a/tests/PalladiumWallet.Tests/PalladiumWallet.Tests.csproj b/tests/PalladiumWallet.Tests/PalladiumWallet.Tests.csproj index a58e472..8ba7e72 100644 --- a/tests/PalladiumWallet.Tests/PalladiumWallet.Tests.csproj +++ b/tests/PalladiumWallet.Tests/PalladiumWallet.Tests.csproj @@ -23,7 +23,8 @@ - + +