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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
87
tests/ScadaLink.Host.Tests/HoconBuilderTests.cs
Normal file
87
tests/ScadaLink.Host.Tests/HoconBuilderTests.cs
Normal 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"));
|
||||
}
|
||||
}
|
||||
67
tests/ScadaLink.Host.Tests/LoggerConfigurationTests.cs
Normal file
67
tests/ScadaLink.Host.Tests/LoggerConfigurationTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
65
tests/ScadaLink.Host.Tests/StartupRetryTests.cs
Normal file
65
tests/ScadaLink.Host.Tests/StartupRetryTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user