using Akka.Configuration; using ScadaLink.ClusterInfrastructure; using ScadaLink.Host.Actors; namespace ScadaLink.Host.Tests; /// /// Host-006: the Akka HOCON document must be assembled with every interpolated value /// quoted/escaped, so a hostname, seed-node URI or split-brain strategy containing a /// quote, backslash or whitespace cannot corrupt the document or be silently /// misparsed. /// public class HoconBuilderTests { private static ClusterOptions DefaultCluster() => new() { SeedNodes = new List { "akka.tcp://scadalink@localhost:8081", "akka.tcp://scadalink@localhost:8082", }, SplitBrainResolverStrategy = "keep-oldest", MinNrOfMembers = 1, StableAfter = TimeSpan.FromSeconds(15), HeartbeatInterval = TimeSpan.FromSeconds(2), FailureDetectionThreshold = TimeSpan.FromSeconds(10), }; [Fact] public void BuildHocon_PlainValues_ParsesAndPreservesValues() { var node = new NodeOptions { Role = "Central", NodeHostname = "central-node1", RemotingPort = 8081, }; var hocon = AkkaHostedService.BuildHocon( node, DefaultCluster(), new[] { "Central" }, TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(15)); var config = ConfigurationFactory.ParseString(hocon); Assert.Equal("central-node1", config.GetString("akka.remote.dot-netty.tcp.hostname")); Assert.Equal("keep-oldest", config.GetString("akka.cluster.split-brain-resolver.active-strategy")); } [Fact] public void BuildHocon_HostnameWithQuote_DoesNotCorruptDocument() { // A hostname containing a double quote would, with raw interpolation, close // the HOCON string literal early and corrupt every following key. var node = new NodeOptions { Role = "Central", NodeHostname = "evil\"host", RemotingPort = 8081, }; var hocon = AkkaHostedService.BuildHocon( node, DefaultCluster(), new[] { "Central" }, TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(15)); // Must still parse, and the keys after the hostname must remain intact. var config = ConfigurationFactory.ParseString(hocon); Assert.Equal("evil\"host", config.GetString("akka.remote.dot-netty.tcp.hostname")); Assert.Equal(8081, config.GetInt("akka.remote.dot-netty.tcp.port")); Assert.Equal(1, config.GetInt("akka.cluster.min-nr-of-members")); } [Fact] public void BuildHocon_StrategyWithWhitespace_RemainsQuoted() { var cluster = DefaultCluster(); cluster.SplitBrainResolverStrategy = "keep oldest"; var node = new NodeOptions { Role = "Central", NodeHostname = "node1", RemotingPort = 8081, }; var hocon = AkkaHostedService.BuildHocon( node, cluster, new[] { "Central" }, TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(15)); var config = ConfigurationFactory.ParseString(hocon); Assert.Equal("keep oldest", config.GetString("akka.cluster.split-brain-resolver.active-strategy")); } private static NodeOptions DefaultNode() => new() { Role = "Central", NodeHostname = "node1", RemotingPort = 8081, }; [Fact] public void BuildHocon_DownIfAloneTrue_EmitsOn() { // Host-012: BuildHocon must consume ClusterOptions.DownIfAlone, not // hard-code the down-if-alone token. var cluster = DefaultCluster(); cluster.DownIfAlone = true; var hocon = AkkaHostedService.BuildHocon( DefaultNode(), cluster, new[] { "Central" }, TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(15)); var config = ConfigurationFactory.ParseString(hocon); Assert.True(config.GetBoolean( "akka.cluster.split-brain-resolver.keep-oldest.down-if-alone")); } [Fact] public void BuildHocon_DownIfAloneFalse_EmitsOff() { // Host-012: setting DownIfAlone=false must actually flip the emitted token. var cluster = DefaultCluster(); cluster.DownIfAlone = false; var hocon = AkkaHostedService.BuildHocon( DefaultNode(), cluster, new[] { "Central" }, TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(15)); var config = ConfigurationFactory.ParseString(hocon); Assert.False(config.GetBoolean( "akka.cluster.split-brain-resolver.keep-oldest.down-if-alone")); } [Fact] public void BuildHocon_SubSecondTimings_PreservedWithoutRounding() { // Host-013: cluster timing values below one second must not be rounded to // whole seconds. A 750ms heartbeat must survive as 750ms, not collapse to // 1s — and a 500ms value must not collapse to a degenerate 0s. var cluster = DefaultCluster(); cluster.HeartbeatInterval = TimeSpan.FromMilliseconds(750); cluster.FailureDetectionThreshold = TimeSpan.FromMilliseconds(2600); cluster.StableAfter = TimeSpan.FromMilliseconds(500); var hocon = AkkaHostedService.BuildHocon( DefaultNode(), cluster, new[] { "Central" }, TimeSpan.FromMilliseconds(2500), TimeSpan.FromMilliseconds(7500)); var config = ConfigurationFactory.ParseString(hocon); Assert.Equal( TimeSpan.FromMilliseconds(750), config.GetTimeSpan("akka.cluster.failure-detector.heartbeat-interval")); Assert.Equal( TimeSpan.FromMilliseconds(2600), config.GetTimeSpan("akka.cluster.failure-detector.acceptable-heartbeat-pause")); Assert.Equal( TimeSpan.FromMilliseconds(500), config.GetTimeSpan("akka.cluster.split-brain-resolver.stable-after")); Assert.Equal( TimeSpan.FromMilliseconds(2500), config.GetTimeSpan("akka.remote.transport-failure-detector.heartbeat-interval")); Assert.Equal( TimeSpan.FromMilliseconds(7500), config.GetTimeSpan("akka.remote.transport-failure-detector.acceptable-heartbeat-pause")); } }