fix(host): resolve Host-005..011 — async startup, HOCON escaping, port-conflict check, dead-config cleanup, migration retry, log-level wiring; Host-002 flagged

This commit is contained in:
Joseph Doherty
2026-05-16 22:24:03 -04:00
parent 3f19371017
commit 8664cdf940
14 changed files with 614 additions and 99 deletions

View File

@@ -4,11 +4,11 @@ namespace ScadaLink.Host.Tests;
/// Host-003: <c>appsettings.Central.json</c> no longer commits database connection
/// strings — they are externalised to environment variables. Tests that exercise the
/// full <c>Program</c> startup pipeline against the real SQL provider must therefore
/// supply the local dev connection strings the way a deployment would: via
/// environment variables (<c>Program</c>'s configuration builder calls
/// supply the local dev connection string the way a deployment would: via an
/// environment variable (<c>Program</c>'s configuration builder calls
/// <c>AddEnvironmentVariables()</c>).
///
/// Dispose restores the previous values so tests stay isolated.
/// Dispose restores the previous value so tests stay isolated.
/// </summary>
internal sealed class CentralDbTestEnvironment : IDisposable
{
@@ -16,26 +16,19 @@ internal sealed class CentralDbTestEnvironment : IDisposable
// This is a test fixture value, not a committed production secret.
private const string ConfigurationDb =
"Server=localhost,1433;Database=ScadaLinkConfig;User Id=scadalink_app;Password=ScadaLink_Dev1#;TrustServerCertificate=true";
private const string MachineDataDb =
"Server=localhost,1433;Database=ScadaLinkMachineData;User Id=scadalink_app;Password=ScadaLink_Dev1#;TrustServerCertificate=true";
private const string ConfigKey = "ScadaLink__Database__ConfigurationDb";
private const string MachineKey = "ScadaLink__Database__MachineDataDb";
private readonly string? _previousConfig;
private readonly string? _previousMachine;
public CentralDbTestEnvironment()
{
_previousConfig = Environment.GetEnvironmentVariable(ConfigKey);
_previousMachine = Environment.GetEnvironmentVariable(MachineKey);
Environment.SetEnvironmentVariable(ConfigKey, ConfigurationDb);
Environment.SetEnvironmentVariable(MachineKey, MachineDataDb);
}
public void Dispose()
{
Environment.SetEnvironmentVariable(ConfigKey, _previousConfig);
Environment.SetEnvironmentVariable(MachineKey, _previousMachine);
}
}

View File

@@ -0,0 +1,87 @@
using Akka.Configuration;
using ScadaLink.ClusterInfrastructure;
using ScadaLink.Host.Actors;
namespace ScadaLink.Host.Tests;
/// <summary>
/// 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.
/// </summary>
public class HoconBuilderTests
{
private static ClusterOptions DefaultCluster() => new()
{
SeedNodes = new List<string>
{
"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" }, 5, 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" }, 5, 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" }, 5, 15);
var config = ConfigurationFactory.ParseString(hocon);
Assert.Equal("keep oldest", config.GetString("akka.cluster.split-brain-resolver.active-strategy"));
}
}

View File

@@ -0,0 +1,67 @@
using Microsoft.Extensions.Configuration;
using Serilog.Events;
namespace ScadaLink.Host.Tests;
/// <summary>
/// Host-011: <c>ScadaLink:Logging:MinimumLevel</c> must actually drive the Serilog
/// minimum level. Previously the value was bound into <see cref="LoggingOptions"/>
/// but never read, so editing it had no effect.
/// </summary>
public class LoggerConfigurationTests
{
private static IConfiguration BuildConfig(string? minimumLevel)
{
var values = new Dictionary<string, string?>();
if (minimumLevel != null)
values["ScadaLink:Logging:MinimumLevel"] = minimumLevel;
return new ConfigurationBuilder().AddInMemoryCollection(values).Build();
}
[Fact]
public void MinimumLevel_Warning_SuppressesInformationLogs()
{
var sink = new InMemorySink();
var logger = LoggerConfigurationFactory
.Build(BuildConfig("Warning"), "Central", "central", "node1")
.WriteTo.Sink(sink)
.CreateLogger();
logger.Information("info message");
logger.Warning("warning message");
Assert.Single(sink.LogEvents);
Assert.Equal(LogEventLevel.Warning, sink.LogEvents[0].Level);
}
[Fact]
public void MinimumLevel_Debug_AllowsDebugLogs()
{
var sink = new InMemorySink();
var logger = LoggerConfigurationFactory
.Build(BuildConfig("Debug"), "Site", "site-a", "node1")
.WriteTo.Sink(sink)
.CreateLogger();
logger.Debug("debug message");
Assert.Single(sink.LogEvents);
Assert.Equal(LogEventLevel.Debug, sink.LogEvents[0].Level);
}
[Fact]
public void MinimumLevel_Absent_DefaultsToInformation()
{
var sink = new InMemorySink();
var logger = LoggerConfigurationFactory
.Build(BuildConfig(null), "Central", "central", "node1")
.WriteTo.Sink(sink)
.CreateLogger();
logger.Debug("debug message");
logger.Information("info message");
Assert.Single(sink.LogEvents);
Assert.Equal(LogEventLevel.Information, sink.LogEvents[0].Level);
}
}

View File

@@ -0,0 +1,65 @@
using Microsoft.Extensions.Logging.Abstractions;
namespace ScadaLink.Host.Tests;
/// <summary>
/// Host-010: startup preconditions (database migration) must tolerate a database
/// that is briefly unavailable at boot — common when an app container and its DB
/// container start together — via a bounded retry with backoff.
/// </summary>
public class StartupRetryTests
{
[Fact]
public async Task ExecuteWithRetry_SucceedsFirstTry_RunsOnce()
{
var attempts = 0;
await StartupRetry.ExecuteWithRetryAsync(
"test-op",
() => { attempts++; return Task.CompletedTask; },
maxAttempts: 5,
initialDelay: TimeSpan.FromMilliseconds(1),
NullLogger.Instance);
Assert.Equal(1, attempts);
}
[Fact]
public async Task ExecuteWithRetry_TransientFailures_RetriesUntilSuccess()
{
var attempts = 0;
await StartupRetry.ExecuteWithRetryAsync(
"test-op",
() =>
{
attempts++;
if (attempts < 3)
throw new InvalidOperationException("db not ready");
return Task.CompletedTask;
},
maxAttempts: 5,
initialDelay: TimeSpan.FromMilliseconds(1),
NullLogger.Instance);
Assert.Equal(3, attempts);
}
[Fact]
public async Task ExecuteWithRetry_ExhaustsAttempts_RethrowsLastException()
{
var attempts = 0;
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
StartupRetry.ExecuteWithRetryAsync(
"test-op",
() =>
{
attempts++;
throw new InvalidOperationException($"failure {attempts}");
},
maxAttempts: 3,
initialDelay: TimeSpan.FromMilliseconds(1),
NullLogger.Instance));
Assert.Equal(3, attempts);
Assert.Equal("failure 3", ex.Message);
}
}

View File

@@ -20,7 +20,6 @@ public class StartupValidatorTests
["ScadaLink:Node:NodeHostname"] = "central-node1",
["ScadaLink:Node:RemotingPort"] = "8081",
["ScadaLink:Database:ConfigurationDb"] = "Server=localhost;Database=Config;",
["ScadaLink:Database:MachineDataDb"] = "Server=localhost;Database=MachineData;",
["ScadaLink:Security:LdapServer"] = "ldap.example.com",
["ScadaLink:Security:JwtSigningKey"] = "test-signing-key-at-least-32-chars-long",
["ScadaLink:Cluster:SeedNodes:0"] = "akka.tcp://scadalink@central-node1:8081",
@@ -151,14 +150,17 @@ public class StartupValidatorTests
}
[Fact]
public void Central_MissingMachineDataDb_FailsValidation()
public void Central_MissingMachineDataDb_PassesValidation()
{
// Host-008 regression: MachineDataDb is never consumed anywhere in the
// system (only ConfigurationDb is wired into AddConfigurationDatabase).
// It is no longer a required key, so its absence must not fail startup.
var values = ValidCentralConfig();
values.Remove("ScadaLink:Database:MachineDataDb");
var config = BuildConfig(values);
var ex = Assert.Throws<InvalidOperationException>(() => StartupValidator.Validate(config));
Assert.Contains("MachineDataDb connection string required for Central", ex.Message);
var ex = Record.Exception(() => StartupValidator.Validate(config));
Assert.Null(ex);
}
[Fact]
@@ -310,6 +312,46 @@ public class StartupValidatorTests
Assert.Null(ex);
}
[Fact]
public void Site_GrpcPortEqualsRemotingPort_FailsValidation()
{
// Host-007 regression: REQ-HOST-4 requires GrpcPort to differ from
// RemotingPort. Identical values cause Kestrel and Akka.Remote to
// contend for the same port at runtime.
var values = ValidSiteConfig();
values["ScadaLink:Node:RemotingPort"] = "8082";
values["ScadaLink:Node:GrpcPort"] = "8082";
var config = BuildConfig(values);
var ex = Assert.Throws<InvalidOperationException>(() => StartupValidator.Validate(config));
Assert.Contains("GrpcPort must differ from RemotingPort", ex.Message);
}
[Fact]
public void Site_DefaultGrpcPortEqualsRemotingPort_FailsValidation()
{
// GrpcPort absent => NodeOptions default 8083. A site whose RemotingPort
// is also 8083 must still be rejected.
var values = ValidSiteConfig();
values["ScadaLink:Node:RemotingPort"] = "8083";
var config = BuildConfig(values);
var ex = Assert.Throws<InvalidOperationException>(() => StartupValidator.Validate(config));
Assert.Contains("GrpcPort must differ from RemotingPort", ex.Message);
}
[Fact]
public void Site_GrpcPortDiffersFromRemotingPort_PassesValidation()
{
var values = ValidSiteConfig();
values["ScadaLink:Node:RemotingPort"] = "8082";
values["ScadaLink:Node:GrpcPort"] = "8083";
var config = BuildConfig(values);
var ex = Record.Exception(() => StartupValidator.Validate(config));
Assert.Null(ex);
}
[Fact]
public void MultipleErrors_AllReported()
{