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.
This commit is contained in:
@@ -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;
|
||||
|
||||
/// <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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user