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"));
}
}