test: cover network/SPV layer end to end (was previously untested)
ElectrumClient, WalletSynchronizer, TransactionInspector, CertificatePinStore and peer discovery went from 0% coverage to close to full coverage, all against the fake ElectrumX server: - ElectrumClient: request/response correlation under load, server errors, notifications, disconnection, cancellation, SSL handshake with TOFU pinning. - WalletSynchronizer: gap-limit scanning, UTXO/history reconstruction, unconfirmed/immature balances, busy-retry, disk-cache reuse, and the security-critical path where a lying server fails Merkle verification and the sync must abort. - TransactionInspector: fee calculation from resolved inputs, mine/theirs attribution, RBF flag, coinbase handling, degraded paths when a previous transaction can't be resolved. - CertificatePinStore: TOFU pin/match/mismatch/reset, corrupted-file fallback. - ServerRegistry: peer discovery and persistence via DiscoverAsync. - UpdateChecker: release-tag parsing and semver comparison.
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using PalladiumWallet.Core.Net;
|
||||
|
||||
namespace PalladiumWallet.Tests.Net;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for TLS trust-on-first-use pinning (§9): first contact pins, matching
|
||||
/// certificate passes, changed certificate is rejected until an explicit reset.
|
||||
/// </summary>
|
||||
public class CertificatePinStoreTests
|
||||
{
|
||||
private static string TempPath() =>
|
||||
Path.Combine(Path.GetTempPath(), $"plm-pins-{Guid.NewGuid()}", "server-certs.json");
|
||||
|
||||
private static X509Certificate2 Cert(string cn) =>
|
||||
FakeElectrumServer.CreateSelfSignedCertificate(cn);
|
||||
|
||||
private static void Cleanup(string path)
|
||||
{
|
||||
if (Directory.Exists(Path.GetDirectoryName(path)))
|
||||
Directory.Delete(Path.GetDirectoryName(path)!, recursive: true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Il_primo_contatto_salva_il_pin_e_lo_stesso_certificato_ripassa()
|
||||
{
|
||||
var path = TempPath();
|
||||
try
|
||||
{
|
||||
using var cert = Cert("server");
|
||||
var store = new CertificatePinStore(path);
|
||||
|
||||
Assert.True(store.VerifyOrPin("host", 50002, cert)); // first use: pinned
|
||||
Assert.True(File.Exists(path));
|
||||
Assert.True(store.VerifyOrPin("host", 50002, cert)); // same cert: ok
|
||||
|
||||
// A fresh instance reads the same file (persistence).
|
||||
Assert.True(new CertificatePinStore(path).VerifyOrPin("host", 50002, cert));
|
||||
}
|
||||
finally { Cleanup(path); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Un_certificato_cambiato_viene_rifiutato()
|
||||
{
|
||||
var path = TempPath();
|
||||
try
|
||||
{
|
||||
using var original = Cert("originale");
|
||||
using var changed = Cert("cambiato");
|
||||
var store = new CertificatePinStore(path);
|
||||
|
||||
Assert.True(store.VerifyOrPin("host", 50002, original));
|
||||
Assert.False(store.VerifyOrPin("host", 50002, changed));
|
||||
// The rejection must not overwrite the pin.
|
||||
Assert.True(store.VerifyOrPin("host", 50002, original));
|
||||
}
|
||||
finally { Cleanup(path); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Host_e_porte_diverse_hanno_pin_indipendenti()
|
||||
{
|
||||
var path = TempPath();
|
||||
try
|
||||
{
|
||||
using var cert1 = Cert("uno");
|
||||
using var cert2 = Cert("due");
|
||||
var store = new CertificatePinStore(path);
|
||||
|
||||
Assert.True(store.VerifyOrPin("host-a", 50002, cert1));
|
||||
Assert.True(store.VerifyOrPin("host-b", 50002, cert2)); // different host
|
||||
Assert.True(store.VerifyOrPin("host-a", 50012, cert2)); // different port
|
||||
Assert.False(store.VerifyOrPin("host-a", 50002, cert2));
|
||||
}
|
||||
finally { Cleanup(path); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Il_reset_di_un_server_permette_di_ripinnare_il_nuovo_certificato()
|
||||
{
|
||||
var path = TempPath();
|
||||
try
|
||||
{
|
||||
using var original = Cert("originale");
|
||||
using var renewed = Cert("rinnovato");
|
||||
var store = new CertificatePinStore(path);
|
||||
|
||||
Assert.True(store.VerifyOrPin("host", 50002, original));
|
||||
Assert.False(store.VerifyOrPin("host", 50002, renewed));
|
||||
|
||||
Assert.True(store.Reset("host", 50002));
|
||||
Assert.True(store.VerifyOrPin("host", 50002, renewed)); // re-pinned
|
||||
Assert.False(store.VerifyOrPin("host", 50002, original)); // old one now rejected
|
||||
}
|
||||
finally { Cleanup(path); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Il_reset_di_un_server_mai_visto_restituisce_false()
|
||||
{
|
||||
var path = TempPath();
|
||||
try
|
||||
{
|
||||
Assert.False(new CertificatePinStore(path).Reset("mai-visto", 50002));
|
||||
}
|
||||
finally { Cleanup(path); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResetAll_cancella_il_file_e_tutti_i_pin()
|
||||
{
|
||||
var path = TempPath();
|
||||
try
|
||||
{
|
||||
using var cert1 = Cert("uno");
|
||||
using var cert2 = Cert("due");
|
||||
var store = new CertificatePinStore(path);
|
||||
store.VerifyOrPin("host-a", 50002, cert1);
|
||||
store.VerifyOrPin("host-b", 50002, cert1);
|
||||
|
||||
store.ResetAll();
|
||||
|
||||
Assert.False(File.Exists(path));
|
||||
Assert.True(store.VerifyOrPin("host-a", 50002, cert2)); // TOFU restarts
|
||||
}
|
||||
finally { Cleanup(path); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Un_file_pin_corrotto_non_blocca_le_connessioni()
|
||||
{
|
||||
var path = TempPath();
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
File.WriteAllText(path, "{ non-json ");
|
||||
|
||||
using var cert = Cert("server");
|
||||
var store = new CertificatePinStore(path);
|
||||
Assert.True(store.VerifyOrPin("host", 50002, cert)); // falls back to first contact
|
||||
Assert.True(store.VerifyOrPin("host", 50002, cert)); // and re-persists correctly
|
||||
}
|
||||
finally { Cleanup(path); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void La_fingerprint_e_sha256_del_certificato_in_hex_minuscolo()
|
||||
{
|
||||
using var cert = Cert("server");
|
||||
var fingerprint = CertificatePinStore.Fingerprint(cert);
|
||||
|
||||
Assert.Equal(64, fingerprint.Length);
|
||||
Assert.Equal(fingerprint.ToLowerInvariant(), fingerprint);
|
||||
Assert.Equal(
|
||||
Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(cert.RawData))
|
||||
.ToLowerInvariant(),
|
||||
fingerprint);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
using System.Text.Json;
|
||||
using PalladiumWallet.Core.Net;
|
||||
|
||||
namespace PalladiumWallet.Tests.Net;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the JSON-RPC transport against an in-process fake ElectrumX server
|
||||
/// (real loopback TCP socket: framing, pipelining, and failure paths are the
|
||||
/// same code paths used in production).
|
||||
/// </summary>
|
||||
public class ElectrumClientTests
|
||||
{
|
||||
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(10);
|
||||
|
||||
[Fact]
|
||||
public async Task Connessione_e_richiesta_fanno_roundtrip()
|
||||
{
|
||||
await using var server = new FakeElectrumServer();
|
||||
server.Handle("server.banner", _ => "benvenuto");
|
||||
|
||||
await using var client = await ElectrumClient.ConnectAsync(server.Host, server.Port, useSsl: false);
|
||||
|
||||
Assert.True(client.IsConnected);
|
||||
Assert.Equal("benvenuto", await client.BannerAsync());
|
||||
// ConnectAsync performs the protocol handshake up front.
|
||||
Assert.Equal(1, server.CallCount("server.version"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Un_errore_del_server_diventa_ElectrumServerException()
|
||||
{
|
||||
await using var server = new FakeElectrumServer();
|
||||
server.Handle("blockchain.transaction.get",
|
||||
_ => throw new FakeElectrumError(-32600, "tx non trovata"));
|
||||
|
||||
await using var client = await ElectrumClient.ConnectAsync(server.Host, server.Port, useSsl: false);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<ElectrumServerException>(
|
||||
() => client.GetTransactionAsync("00"));
|
||||
Assert.Contains("tx non trovata", ex.Message);
|
||||
Assert.Contains("-32600", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Richieste_parallele_ricevono_ciascuna_la_propria_risposta()
|
||||
{
|
||||
await using var server = new FakeElectrumServer();
|
||||
server.Handle("echo", p => p[0].GetInt32());
|
||||
|
||||
await using var client = await ElectrumClient.ConnectAsync(server.Host, server.Port, useSsl: false);
|
||||
|
||||
// More requests than the in-flight cap (32): exercises both the write
|
||||
// batching and the id → response correlation.
|
||||
var tasks = Enumerable.Range(0, 200)
|
||||
.Select(i => client.RequestAsync("echo", default, i))
|
||||
.ToList();
|
||||
var results = await Task.WhenAll(tasks);
|
||||
|
||||
for (var i = 0; i < results.Length; i++)
|
||||
Assert.Equal(i, results[i].GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Le_notifiche_arrivano_sull_evento_NotificationReceived()
|
||||
{
|
||||
await using var server = new FakeElectrumServer();
|
||||
await using var client = await ElectrumClient.ConnectAsync(server.Host, server.Port, useSsl: false);
|
||||
|
||||
var received = new TaskCompletionSource<(string Method, JsonElement Params)>(
|
||||
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
client.NotificationReceived += (method, p) => received.TrySetResult((method, p));
|
||||
|
||||
await server.NotifyAsync("blockchain.headers.subscribe", new object[]
|
||||
{
|
||||
new { height = 4242, hex = "00" },
|
||||
});
|
||||
|
||||
var (method, parameters) = await received.Task.WaitAsync(Timeout);
|
||||
Assert.Equal("blockchain.headers.subscribe", method);
|
||||
Assert.Equal(4242, parameters[0].GetProperty("height").GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task La_chiusura_del_server_fa_fallire_le_richieste_pendenti_e_notifica_Disconnected()
|
||||
{
|
||||
await using var server = new FakeElectrumServer();
|
||||
server.Handle("server.banner", _ => FakeElectrumServer.NoResponse);
|
||||
|
||||
await using var client = await ElectrumClient.ConnectAsync(server.Host, server.Port, useSsl: false);
|
||||
|
||||
var disconnected = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
client.Disconnected += _ => disconnected.TrySetResult();
|
||||
|
||||
var pending = client.BannerAsync();
|
||||
server.DropClients();
|
||||
|
||||
await Assert.ThrowsAnyAsync<Exception>(() => pending.WaitAsync(Timeout));
|
||||
await disconnected.Task.WaitAsync(Timeout);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task La_cancellazione_del_token_annulla_la_richiesta_pendente()
|
||||
{
|
||||
await using var server = new FakeElectrumServer();
|
||||
server.Handle("server.banner", _ => FakeElectrumServer.NoResponse);
|
||||
|
||||
await using var client = await ElectrumClient.ConnectAsync(server.Host, server.Port, useSsl: false);
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
var pending = client.BannerAsync(cts.Token);
|
||||
cts.Cancel();
|
||||
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => pending.WaitAsync(Timeout));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task I_wrapper_tipizzati_del_protocollo_parsano_le_risposte()
|
||||
{
|
||||
await using var server = new FakeElectrumServer();
|
||||
server.Handle("blockchain.scripthash.listunspent", _ => new object[]
|
||||
{
|
||||
new { tx_hash = "aa".PadLeft(64, '0'), tx_pos = 1, value = 12_345L, height = 99 },
|
||||
});
|
||||
server.Handle("blockchain.transaction.broadcast", p => p[0].GetString()!.Length.ToString());
|
||||
server.Handle("blockchain.estimatefee", _ => 0.00012m);
|
||||
server.Handle("blockchain.relayfee", _ => 0.00001m);
|
||||
server.Handle("server.ping", _ => null);
|
||||
|
||||
await using var client = await ElectrumClient.ConnectAsync(server.Host, server.Port, useSsl: false);
|
||||
|
||||
var unspent = Assert.Single(await client.ListUnspentAsync("sh"));
|
||||
Assert.Equal(12_345L, unspent.ValueSats);
|
||||
Assert.Equal(1, unspent.TxPos);
|
||||
Assert.Equal(99, unspent.Height);
|
||||
|
||||
Assert.Equal("4", await client.BroadcastAsync("beef"));
|
||||
Assert.Equal(0.00012m, await client.EstimateFeeAsync(2));
|
||||
Assert.Equal(0.00001m, await client.RelayFeeAsync());
|
||||
await client.PingAsync(); // must not throw
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ssl_con_tofu_accetta_il_primo_certificato_e_rifiuta_un_certificato_cambiato()
|
||||
{
|
||||
var pinsPath = Path.Combine(Path.GetTempPath(), $"plm-pins-{Guid.NewGuid()}.json");
|
||||
try
|
||||
{
|
||||
var pins = new CertificatePinStore(pinsPath);
|
||||
int port;
|
||||
|
||||
// First contact: any certificate is pinned (trust on first use).
|
||||
using (var cert1 = FakeElectrumServer.CreateSelfSignedCertificate("primo"))
|
||||
{
|
||||
await using var server = new FakeElectrumServer(cert1);
|
||||
port = server.Port;
|
||||
await using var client = await ElectrumClient.ConnectAsync(
|
||||
server.Host, port, useSsl: true, pins);
|
||||
Assert.True(client.IsConnected);
|
||||
Assert.True(client.UseSsl);
|
||||
}
|
||||
|
||||
// Same host:port, different certificate: the pin must block the connection.
|
||||
using (var cert2 = FakeElectrumServer.CreateSelfSignedCertificate("secondo"))
|
||||
{
|
||||
await using var server = await RebindAsync(cert2, port);
|
||||
await Assert.ThrowsAsync<CertificatePinMismatchException>(() =>
|
||||
ElectrumClient.ConnectAsync(server.Host, port, useSsl: true, pins));
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(pinsPath);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Rebinds a fresh server to the port just released by the previous one.</summary>
|
||||
private static async Task<FakeElectrumServer> RebindAsync(
|
||||
System.Security.Cryptography.X509Certificates.X509Certificate2 cert, int port)
|
||||
{
|
||||
for (var attempt = 0; ; attempt++)
|
||||
{
|
||||
try { return new FakeElectrumServer(cert, port); }
|
||||
catch (System.Net.Sockets.SocketException) when (attempt < 20)
|
||||
{
|
||||
await Task.Delay(50);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -79,6 +79,49 @@ public class ServerRegistryTests
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task La_discovery_aggiunge_i_peer_annunciati_e_li_persiste()
|
||||
{
|
||||
var path = TempPath();
|
||||
try
|
||||
{
|
||||
await using var server = new FakeElectrumServer();
|
||||
server.Handle("server.peers.subscribe", _ => new object[]
|
||||
{
|
||||
// Full announcement, port-less announcement (→ profile defaults),
|
||||
// and a bootstrap duplicate that must not be re-added.
|
||||
new object[] { "10.0.0.9", "nuovo.esempio.org", new[] { "v1.4.2", "t50001", "s50002" } },
|
||||
new object[] { "10.0.0.8", "senza-porte.esempio.org", new[] { "v1.4", "t", "s" } },
|
||||
new object[] { "173.212.224.67", "173.212.224.67", new[] { "v1.4", "t50001" } },
|
||||
});
|
||||
await using var client = await ElectrumClient.ConnectAsync(server.Host, server.Port, useSsl: false);
|
||||
|
||||
var registry = new ServerRegistry(ChainProfiles.Mainnet, path);
|
||||
var bootstrapCount = registry.All.Count;
|
||||
|
||||
var added = await registry.DiscoverAsync(client);
|
||||
|
||||
Assert.Equal(2, added);
|
||||
Assert.Equal(bootstrapCount + 2, registry.All.Count);
|
||||
var defaulted = registry.All.Single(s => s.Host == "senza-porte.esempio.org");
|
||||
Assert.Equal(ChainProfiles.Mainnet.DefaultTcpPort, defaulted.TcpPort);
|
||||
Assert.Equal(ChainProfiles.Mainnet.DefaultSslPort, defaulted.SslPort);
|
||||
|
||||
// Persisted for the next session (bootstrap servers excluded from the file).
|
||||
var reloaded = new ServerRegistry(ChainProfiles.Mainnet, path);
|
||||
Assert.Equal(bootstrapCount + 2, reloaded.All.Count);
|
||||
var savedJson = File.ReadAllText(path);
|
||||
Assert.DoesNotContain("173.212.224.67", savedJson);
|
||||
|
||||
// A second discovery of the same peers adds nothing.
|
||||
Assert.Equal(0, await registry.DiscoverAsync(client));
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Un_file_server_corrotto_non_blocca_l_avvio()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
using PalladiumWallet.Core.Net;
|
||||
|
||||
namespace PalladiumWallet.Tests.Net;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the release-tag parsing used by the update check. The network
|
||||
/// call itself is best-effort by design (any failure → null) and is not
|
||||
/// exercised here: only the version-comparison logic is deterministic.
|
||||
/// </summary>
|
||||
public class UpdateCheckerTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("v1.2.3", "1.2.3")]
|
||||
[InlineData("V0.9.1", "0.9.1")]
|
||||
[InlineData("1.2.3", "1.2.3")]
|
||||
[InlineData("0.9.0-beta", "0.9.0")]
|
||||
[InlineData("v2.0.0-rc.1", "2.0.0")]
|
||||
[InlineData(" 1.4 ", "1.4")]
|
||||
public void I_tag_di_release_si_parsano_nella_versione_attesa(string tag, string expected)
|
||||
{
|
||||
Assert.True(UpdateChecker.TryParse(tag, out var version));
|
||||
Assert.Equal(Version.Parse(expected), version);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("main")]
|
||||
[InlineData("v")]
|
||||
[InlineData("1")]
|
||||
[InlineData("non-una-versione")]
|
||||
public void I_tag_non_versionati_vengono_rifiutati(string tag)
|
||||
{
|
||||
Assert.False(UpdateChecker.TryParse(tag, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Il_confronto_remota_maggiore_di_locale_segue_l_ordine_semver()
|
||||
{
|
||||
// The same comparison CheckAsync applies between remote tag and running app.
|
||||
Assert.True(UpdateChecker.TryParse("v0.10.0", out var remote));
|
||||
Assert.True(UpdateChecker.TryParse("0.9.0", out var local));
|
||||
Assert.True(remote > local); // 0.10 > 0.9: numeric, not lexicographic
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user