diff --git a/src/Core/Net/UpdateChecker.cs b/src/Core/Net/UpdateChecker.cs index 2d1482f..80ce8c1 100644 --- a/src/Core/Net/UpdateChecker.cs +++ b/src/Core/Net/UpdateChecker.cs @@ -55,7 +55,7 @@ public static class UpdateChecker } /// Parses "v1.2.3" / "1.2.3-beta" style tags into a comparable . - private static bool TryParse(string raw, out Version version) + internal static bool TryParse(string raw, out Version version) { var s = raw.Trim(); if (s.StartsWith('v') || s.StartsWith('V')) s = s[1..]; diff --git a/src/Core/PalladiumWallet.Core.csproj b/src/Core/PalladiumWallet.Core.csproj index 27a502a..3555b71 100644 --- a/src/Core/PalladiumWallet.Core.csproj +++ b/src/Core/PalladiumWallet.Core.csproj @@ -6,8 +6,12 @@ enable - - + + + + + + diff --git a/tests/PalladiumWallet.Tests/Net/FakeElectrumServer.cs b/tests/PalladiumWallet.Tests/Net/FakeElectrumServer.cs new file mode 100644 index 0000000..5b55e73 --- /dev/null +++ b/tests/PalladiumWallet.Tests/Net/FakeElectrumServer.cs @@ -0,0 +1,212 @@ +using System.Collections.Concurrent; +using System.Net; +using System.Net.Security; +using System.Net.Sockets; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using System.Text.Json; + +namespace PalladiumWallet.Tests.Net; + +/// +/// Error a handler can throw to make the fake server reply with a JSON-RPC +/// error object (e.g. code -102 "server busy" to exercise the retry path). +/// +public sealed class FakeElectrumError(int code, string message) : Exception(message) +{ + public int Code { get; } = code; +} + +/// +/// In-process ElectrumX-like server for tests: newline-delimited JSON-RPC 2.0 +/// over a loopback TCP socket, optionally TLS with a self-signed certificate. +/// Handlers are registered per method and receive the request "params" array; +/// whatever they return is serialised as the "result". "server.version" is +/// pre-registered. Calls are counted per method (cache/retry assertions). +/// +public sealed class FakeElectrumServer : IAsyncDisposable +{ + private readonly TcpListener _listener; + private readonly CancellationTokenSource _cts = new(); + private readonly Task _acceptLoop; + private readonly X509Certificate2? _certificate; + private readonly ConcurrentDictionary> _handlers = new(); + private readonly ConcurrentDictionary _callCounts = new(); + private readonly ConcurrentBag<(Stream Stream, SemaphoreSlim WriteLock, TcpClient Tcp)> _clients = []; + + /// Sentinel: a handler returning this leaves the request unanswered (pending forever). + public static readonly object NoResponse = new(); + + public FakeElectrumServer(X509Certificate2? certificate = null, int port = 0) + { + _certificate = certificate; + _listener = new TcpListener(IPAddress.Loopback, port); + _listener.Start(); + Handle("server.version", _ => new[] { "FakeElectrumX 1.0", "1.4" }); + _acceptLoop = Task.Run(AcceptLoopAsync); + } + + public int Port => ((IPEndPoint)_listener.LocalEndpoint).Port; + public string Host => "127.0.0.1"; + + /// Registers (or replaces) the handler for a JSON-RPC method. + public void Handle(string method, Func handler) => + _handlers[method] = handler; + + public int CallCount(string method) => _callCounts.GetValueOrDefault(method); + + public void ResetCallCounts() => _callCounts.Clear(); + + /// Pushes a JSON-RPC notification to every connected client. + public async Task NotifyAsync(string method, object parameters) + { + var line = JsonSerializer.SerializeToUtf8Bytes(new + { + jsonrpc = "2.0", + method, + @params = parameters, + }); + foreach (var (stream, writeLock, _) in _clients) + await WriteLineAsync(stream, writeLock, line); + } + + /// Abruptly closes every accepted connection (tests the client's failure paths). + public void DropClients() + { + foreach (var (_, _, tcp) in _clients) + tcp.Close(); + } + + /// Self-signed certificate for TLS tests (exportable, valid now). + public static X509Certificate2 CreateSelfSignedCertificate(string cn = "fake-electrum") + { + using var rsa = RSA.Create(2048); + var request = new CertificateRequest( + $"CN={cn}", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + using var cert = request.CreateSelfSigned( + DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(30)); + // PFX roundtrip so the private key is usable by SslStream on every platform. + return X509CertificateLoader.LoadPkcs12(cert.Export(X509ContentType.Pfx), null); + } + + private async Task AcceptLoopAsync() + { + try + { + while (!_cts.IsCancellationRequested) + { + var tcp = await _listener.AcceptTcpClientAsync(_cts.Token); + _ = Task.Run(() => ServeClientAsync(tcp)); + } + } + catch (OperationCanceledException) { } + catch (SocketException) { } + } + + private async Task ServeClientAsync(TcpClient tcp) + { + try + { + Stream stream = tcp.GetStream(); + if (_certificate is not null) + { + var ssl = new SslStream(stream, leaveInnerStreamOpen: false); + await ssl.AuthenticateAsServerAsync(_certificate); + stream = ssl; + } + + var writeLock = new SemaphoreSlim(1, 1); + _clients.Add((stream, writeLock, tcp)); + + using var reader = new StreamReader(stream, Encoding.UTF8, false, 4096, leaveOpen: true); + while (!_cts.IsCancellationRequested) + { + var line = await reader.ReadLineAsync(_cts.Token); + if (line is null) break; + if (line.Length == 0) continue; + await HandleRequestAsync(stream, writeLock, line); + } + } + catch (Exception) + { + // Client went away or TLS failed (e.g. rejected pin): normal in tests. + } + } + + private async Task HandleRequestAsync(Stream stream, SemaphoreSlim writeLock, string line) + { + using var doc = JsonDocument.Parse(line); + var root = doc.RootElement; + var id = root.GetProperty("id").GetInt64(); + var method = root.GetProperty("method").GetString()!; + var parameters = root.TryGetProperty("params", out var p) ? p : default; + + _callCounts.AddOrUpdate(method, 1, (_, n) => n + 1); + + byte[] payload; + if (!_handlers.TryGetValue(method, out var handler)) + { + payload = JsonSerializer.SerializeToUtf8Bytes(new + { + jsonrpc = "2.0", + id, + error = new { code = -32601, message = $"unknown method '{method}'" }, + }); + } + else + { + try + { + var result = handler(parameters); + if (ReferenceEquals(result, NoResponse)) + return; + payload = JsonSerializer.SerializeToUtf8Bytes(new + { + jsonrpc = "2.0", + id, + result, + }); + } + catch (FakeElectrumError err) + { + payload = JsonSerializer.SerializeToUtf8Bytes(new + { + jsonrpc = "2.0", + id, + error = new { code = err.Code, message = err.Message }, + }); + } + } + + await WriteLineAsync(stream, writeLock, payload); + } + + private static async Task WriteLineAsync(Stream stream, SemaphoreSlim writeLock, byte[] payload) + { + await writeLock.WaitAsync(); + try + { + await stream.WriteAsync(payload); + stream.WriteByte((byte)'\n'); + await stream.FlushAsync(); + } + catch (Exception) + { + // Connection already gone: irrelevant for the test in progress. + } + finally + { + writeLock.Release(); + } + } + + public async ValueTask DisposeAsync() + { + await _cts.CancelAsync(); + _listener.Stop(); + DropClients(); + try { await _acceptLoop; } catch { } + _cts.Dispose(); + } +}