Files
PalladiumWallet/tests/PalladiumWallet.Tests/Net/FakeElectrumServer.cs
T
davide e8ff99a768 test: add in-process fake ElectrumX server for network-layer testing
Real loopback TCP socket speaking newline-delimited JSON-RPC 2.0, optionally
TLS with a self-signed certificate, with per-method handlers and call
counters. Lets ElectrumClient, WalletSynchronizer, TransactionInspector, and
CertificatePinStore be exercised against the same code paths used in
production instead of being mocked.

Also exposes UpdateChecker's tag-parsing logic internally so its version
comparison can be tested without a real GitHub API call.
2026-07-02 23:20:32 +02:00

213 lines
7.4 KiB
C#

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;
/// <summary>
/// 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).
/// </summary>
public sealed class FakeElectrumError(int code, string message) : Exception(message)
{
public int Code { get; } = code;
}
/// <summary>
/// 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).
/// </summary>
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<string, Func<JsonElement, object?>> _handlers = new();
private readonly ConcurrentDictionary<string, int> _callCounts = new();
private readonly ConcurrentBag<(Stream Stream, SemaphoreSlim WriteLock, TcpClient Tcp)> _clients = [];
/// <summary>Sentinel: a handler returning this leaves the request unanswered (pending forever).</summary>
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";
/// <summary>Registers (or replaces) the handler for a JSON-RPC method.</summary>
public void Handle(string method, Func<JsonElement, object?> handler) =>
_handlers[method] = handler;
public int CallCount(string method) => _callCounts.GetValueOrDefault(method);
public void ResetCallCounts() => _callCounts.Clear();
/// <summary>Pushes a JSON-RPC notification to every connected client.</summary>
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);
}
/// <summary>Abruptly closes every accepted connection (tests the client's failure paths).</summary>
public void DropClients()
{
foreach (var (_, _, tcp) in _clients)
tcp.Close();
}
/// <summary>Self-signed certificate for TLS tests (exportable, valid now).</summary>
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();
}
}