Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure.Tests/ClusterRemotingTlsValidatorTests.cs
T
Joseph Doherty 2e6102d48f feat(cluster): opt-in Akka.Remote TLS block, default off (ScadaBridge#64)
ScadaBridge:Cluster:RemotingTls (ClusterRemotingTlsOptions) renders
akka.remote.dot-netty.tcp enable-ssl + ssl { certificate, mutual auth,
hostname validation } only when Enabled. Chain validation is always on
(no suppress-validation switch). Certificate is a .pfx path + password
(${secret:} reference) or an OS-store thumbprint.

ClusterOptionsValidator, TLS on only: exactly one certificate source, a
legal store location, and akka.ssl.tcp:// on every seed - Akka.Remote
advertises akka.ssl.tcp addresses once SSL is on, so an akka.tcp seed is
never answered and the join retries forever with no startup error.

ClusterBootstrapGuard.SeedPattern accepted only one scheme segment, so
akka.ssl.tcp seeds parsed as nothing and the guard would silently never
engage on a TLS pair; it now accepts any number.

Default HOCON is byte-identical: 12 rendered documents (3 downing
strategies x guard on/off x Central/Site) captured before the change and
diffed after, all identical; a permanent test pins that a disabled block
with every field set renders the default document. A real ActorSystem
booted on the emitted TLS document comes up as akka.ssl.tcp (mutation
check: forcing enable-ssl=false turns it red).

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 17:06:01 -05:00

118 lines
4.6 KiB
C#

namespace ZB.MOM.WW.ScadaBridge.ClusterInfrastructure.Tests;
/// <summary>
/// <see cref="ClusterOptionsValidator"/> for the opt-in Akka.Remote TLS block
/// (<c>ScadaBridge:Cluster:RemotingTls</c>, ScadaBridge#64). Disabled must check nothing — so the
/// default configuration can never be blocked by it — and enabled must refuse the shapes that would
/// boot into a pair that silently never joins.
/// </summary>
public class ClusterRemotingTlsValidatorTests
{
private static ClusterOptions Options(bool tlsSeeds, ClusterRemotingTlsOptions tls)
{
var scheme = tlsSeeds ? "akka.ssl.tcp" : "akka.tcp";
return new ClusterOptions
{
SeedNodes = new List<string> { $"{scheme}://scadabridge@node1:8081", $"{scheme}://scadabridge@node2:8081" },
SplitBrainResolverStrategy = "auto-down",
RemotingTls = tls,
};
}
private static ClusterRemotingTlsOptions PathTls() => new()
{
Enabled = true,
CertificatePath = "/certs/node1.pfx",
CertificatePassword = "pw",
};
/// <summary>Verifies TLS is off by default.</summary>
[Fact]
public void RemotingTls_is_off_by_default()
{
Assert.False(new ClusterOptions().RemotingTls.Enabled);
}
/// <summary>
/// Verifies a disabled block carrying garbage (both sources, a bad store location) is not checked:
/// with TLS off the fields are inert, and validating them would let an unused block break a boot.
/// </summary>
[Fact]
public void Disabled_block_is_not_validated()
{
var tls = new ClusterRemotingTlsOptions
{
Enabled = false,
CertificatePath = "/x.pfx",
CertificateThumbprint = "ABC",
StoreLocation = "nowhere",
};
var result = new ClusterOptionsValidator().Validate(null, Options(tlsSeeds: false, tls));
Assert.True(result.Succeeded, result.FailureMessage);
}
/// <summary>Verifies a file-certificate TLS config with akka.ssl.tcp seeds is valid.</summary>
[Fact]
public void Enabled_with_path_and_tls_seeds_is_valid()
{
var result = new ClusterOptionsValidator().Validate(null, Options(tlsSeeds: true, PathTls()));
Assert.True(result.Succeeded, result.FailureMessage);
}
/// <summary>Verifies a store-thumbprint TLS config with akka.ssl.tcp seeds is valid.</summary>
[Fact]
public void Enabled_with_thumbprint_and_tls_seeds_is_valid()
{
var tls = new ClusterRemotingTlsOptions { Enabled = true, CertificateThumbprint = "45df32e258c92a7abf6c112e54912ab15bbb9eb0" };
var result = new ClusterOptionsValidator().Validate(null, Options(tlsSeeds: true, tls));
Assert.True(result.Succeeded, result.FailureMessage);
}
/// <summary>
/// Verifies TLS with akka.tcp seeds is refused: Akka.Remote advertises akka.ssl.tcp once SSL is on,
/// so those seeds name an address nobody answers and the join retries forever.
/// </summary>
[Fact]
public void Enabled_with_plain_tcp_seeds_fails()
{
var result = new ClusterOptionsValidator().Validate(null, Options(tlsSeeds: false, PathTls()));
Assert.True(result.Failed);
Assert.Contains("akka.ssl.tcp://", result.FailureMessage);
Assert.Contains("akka.tcp://scadabridge@node1:8081", result.FailureMessage);
}
/// <summary>Verifies exactly one certificate source is required.</summary>
/// <param name="path">The certificate path, or null.</param>
/// <param name="thumbprint">The thumbprint, or null.</param>
[Theory]
[InlineData(null, null)]
[InlineData("/certs/node1.pfx", "45df32e258c92a7abf6c112e54912ab15bbb9eb0")]
public void Enabled_requires_exactly_one_certificate_source(string? path, string? thumbprint)
{
var tls = new ClusterRemotingTlsOptions { Enabled = true, CertificatePath = path, CertificateThumbprint = thumbprint };
var result = new ClusterOptionsValidator().Validate(null, Options(tlsSeeds: true, tls));
Assert.True(result.Failed);
Assert.Contains("exactly one certificate source", result.FailureMessage);
}
/// <summary>Verifies an unknown store location is refused for a thumbprint certificate.</summary>
[Fact]
public void Enabled_thumbprint_rejects_unknown_store_location()
{
var tls = new ClusterRemotingTlsOptions { Enabled = true, CertificateThumbprint = "ABC", StoreLocation = "LocalMachine" };
var result = new ClusterOptionsValidator().Validate(null, Options(tlsSeeds: true, tls));
Assert.True(result.Failed);
Assert.Contains("StoreLocation", result.FailureMessage);
}
}