Phase 1 WP-11–22: Host infrastructure, Blazor Server UI, and integration tests
Host infrastructure (WP-11–17): - StartupValidator with 19 validation rules - /health/ready endpoint with DB + Akka health checks - Akka.NET bootstrap via AkkaHostedService (HOCON config, cluster, remoting, SBR) - Serilog with SiteId/NodeHostname/NodeRole enrichment - DeadLetterMonitorActor with count tracking - CoordinatedShutdown wiring (no Environment.Exit) - Windows Service support (UseWindowsService) Central UI (WP-18–21): - Blazor Server shell with Bootstrap 5, role-aware NavMenu - Login/logout flow (LDAP auth → JWT → HTTP-only cookie) - CookieAuthenticationStateProvider with idle timeout - LDAP group mapping CRUD page (Admin role) - Route guards with Authorize attributes per role - SignalR reconnection overlay for failover Integration tests (WP-22): - Startup validation, auth flow, audit transactions, readiness gating 186 tests pass (1 skipped: LDAP integration), zero warnings.
This commit is contained in:
80
tests/ScadaLink.Host.Tests/AkkaBootstrapTests.cs
Normal file
80
tests/ScadaLink.Host.Tests/AkkaBootstrapTests.cs
Normal file
@@ -0,0 +1,80 @@
|
||||
using Akka.Actor;
|
||||
using Akka.Configuration;
|
||||
|
||||
namespace ScadaLink.Host.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// WP-13: Tests for Akka.NET actor system bootstrap.
|
||||
/// </summary>
|
||||
public class AkkaBootstrapTests : IDisposable
|
||||
{
|
||||
private ActorSystem? _actorSystem;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_actorSystem?.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ActorSystem_CreatesWithClusterConfig()
|
||||
{
|
||||
var hocon = @"
|
||||
akka {
|
||||
actor {
|
||||
provider = cluster
|
||||
}
|
||||
remote {
|
||||
dot-netty.tcp {
|
||||
hostname = ""localhost""
|
||||
port = 0
|
||||
}
|
||||
}
|
||||
cluster {
|
||||
seed-nodes = [""akka.tcp://scadalink-test@localhost:0""]
|
||||
roles = [""Central""]
|
||||
min-nr-of-members = 1
|
||||
}
|
||||
coordinated-shutdown {
|
||||
run-by-clr-shutdown-hook = on
|
||||
}
|
||||
}";
|
||||
var config = ConfigurationFactory.ParseString(hocon);
|
||||
_actorSystem = ActorSystem.Create("scadalink-test", config);
|
||||
|
||||
Assert.NotNull(_actorSystem);
|
||||
Assert.Equal("scadalink-test", _actorSystem.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ActorSystem_HoconConfig_IncludesCoordinatedShutdown()
|
||||
{
|
||||
var hocon = @"
|
||||
akka {
|
||||
actor {
|
||||
provider = cluster
|
||||
}
|
||||
remote {
|
||||
dot-netty.tcp {
|
||||
hostname = ""localhost""
|
||||
port = 0
|
||||
}
|
||||
}
|
||||
cluster {
|
||||
seed-nodes = [""akka.tcp://scadalink-test@localhost:0""]
|
||||
roles = [""Central""]
|
||||
run-coordinated-shutdown-when-down = on
|
||||
}
|
||||
coordinated-shutdown {
|
||||
run-by-clr-shutdown-hook = on
|
||||
}
|
||||
}";
|
||||
var config = ConfigurationFactory.ParseString(hocon);
|
||||
_actorSystem = ActorSystem.Create("scadalink-cs-test", config);
|
||||
|
||||
var csConfig = _actorSystem.Settings.Config.GetString("akka.coordinated-shutdown.run-by-clr-shutdown-hook");
|
||||
Assert.Equal("on", csConfig);
|
||||
|
||||
var clusterShutdown = _actorSystem.Settings.Config.GetString("akka.cluster.run-coordinated-shutdown-when-down");
|
||||
Assert.Equal("on", clusterShutdown);
|
||||
}
|
||||
}
|
||||
60
tests/ScadaLink.Host.Tests/CoordinatedShutdownTests.cs
Normal file
60
tests/ScadaLink.Host.Tests/CoordinatedShutdownTests.cs
Normal file
@@ -0,0 +1,60 @@
|
||||
using System.Reflection;
|
||||
|
||||
namespace ScadaLink.Host.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// WP-16: Tests for CoordinatedShutdown configuration.
|
||||
/// Verifies no Environment.Exit calls exist in source and HOCON config is correct.
|
||||
/// </summary>
|
||||
public class CoordinatedShutdownTests
|
||||
{
|
||||
[Fact]
|
||||
public void HostSource_DoesNotContainEnvironmentExit()
|
||||
{
|
||||
var hostProjectDir = FindHostProjectDirectory();
|
||||
Assert.NotNull(hostProjectDir);
|
||||
|
||||
var sourceFiles = Directory.GetFiles(hostProjectDir, "*.cs", SearchOption.AllDirectories);
|
||||
Assert.NotEmpty(sourceFiles);
|
||||
|
||||
foreach (var file in sourceFiles)
|
||||
{
|
||||
var content = File.ReadAllText(file);
|
||||
Assert.DoesNotContain("Environment.Exit", content,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AkkaHostedService_HoconConfig_IncludesCoordinatedShutdownSettings()
|
||||
{
|
||||
// Read the AkkaHostedService source to verify HOCON configuration
|
||||
var hostProjectDir = FindHostProjectDirectory();
|
||||
Assert.NotNull(hostProjectDir);
|
||||
|
||||
var akkaServiceFile = Path.Combine(hostProjectDir, "Actors", "AkkaHostedService.cs");
|
||||
Assert.True(File.Exists(akkaServiceFile), $"AkkaHostedService.cs not found at {akkaServiceFile}");
|
||||
|
||||
var content = File.ReadAllText(akkaServiceFile);
|
||||
|
||||
// Verify critical HOCON settings are present
|
||||
Assert.Contains("run-by-clr-shutdown-hook = on", content);
|
||||
Assert.Contains("run-coordinated-shutdown-when-down = on", content);
|
||||
}
|
||||
|
||||
private static string? FindHostProjectDirectory()
|
||||
{
|
||||
var assemblyDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!;
|
||||
var dir = new DirectoryInfo(assemblyDir);
|
||||
|
||||
while (dir != null)
|
||||
{
|
||||
var hostPath = Path.Combine(dir.FullName, "src", "ScadaLink.Host");
|
||||
if (Directory.Exists(hostPath))
|
||||
return hostPath;
|
||||
dir = dir.Parent;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
74
tests/ScadaLink.Host.Tests/DeadLetterMonitorTests.cs
Normal file
74
tests/ScadaLink.Host.Tests/DeadLetterMonitorTests.cs
Normal file
@@ -0,0 +1,74 @@
|
||||
using Akka.Actor;
|
||||
using Akka.Event;
|
||||
using Akka.TestKit.Xunit2;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ScadaLink.Host.Actors;
|
||||
|
||||
namespace ScadaLink.Host.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// WP-15: Tests for DeadLetterMonitorActor.
|
||||
/// </summary>
|
||||
public class DeadLetterMonitorTests : TestKit
|
||||
{
|
||||
private readonly ILogger<DeadLetterMonitorActor> _logger =
|
||||
NullLoggerFactory.Instance.CreateLogger<DeadLetterMonitorActor>();
|
||||
|
||||
[Fact]
|
||||
public void DeadLetterMonitor_StartsWithZeroCount()
|
||||
{
|
||||
var monitor = Sys.ActorOf(Props.Create(() => new DeadLetterMonitorActor(_logger)));
|
||||
|
||||
monitor.Tell(GetDeadLetterCount.Instance);
|
||||
var response = ExpectMsg<DeadLetterCountResponse>();
|
||||
|
||||
Assert.Equal(0, response.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeadLetterMonitor_IncrementsOnDeadLetter()
|
||||
{
|
||||
var monitor = Sys.ActorOf(Props.Create(() => new DeadLetterMonitorActor(_logger)));
|
||||
|
||||
// Ensure actor has started and subscribed by sending a message and waiting for response
|
||||
monitor.Tell(GetDeadLetterCount.Instance);
|
||||
ExpectMsg<DeadLetterCountResponse>();
|
||||
|
||||
// Now publish dead letters — actor is guaranteed to be subscribed
|
||||
Sys.EventStream.Publish(new DeadLetter("test-message-1", Sys.DeadLetters, Sys.DeadLetters));
|
||||
Sys.EventStream.Publish(new DeadLetter("test-message-2", Sys.DeadLetters, Sys.DeadLetters));
|
||||
|
||||
// Use AwaitAssert to handle async event delivery
|
||||
AwaitAssert(() =>
|
||||
{
|
||||
monitor.Tell(GetDeadLetterCount.Instance);
|
||||
var response = ExpectMsg<DeadLetterCountResponse>();
|
||||
Assert.Equal(2, response.Count);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeadLetterMonitor_CountAccumulates()
|
||||
{
|
||||
var monitor = Sys.ActorOf(Props.Create(() => new DeadLetterMonitorActor(_logger)));
|
||||
|
||||
// Ensure actor is started and subscribed
|
||||
monitor.Tell(GetDeadLetterCount.Instance);
|
||||
ExpectMsg<DeadLetterCountResponse>();
|
||||
|
||||
// Send 5 dead letters
|
||||
for (var i = 0; i < 5; i++)
|
||||
{
|
||||
Sys.EventStream.Publish(
|
||||
new DeadLetter($"message-{i}", Sys.DeadLetters, Sys.DeadLetters));
|
||||
}
|
||||
|
||||
AwaitAssert(() =>
|
||||
{
|
||||
monitor.Tell(GetDeadLetterCount.Instance);
|
||||
var response = ExpectMsg<DeadLetterCountResponse>();
|
||||
Assert.Equal(5, response.Count);
|
||||
});
|
||||
}
|
||||
}
|
||||
66
tests/ScadaLink.Host.Tests/HealthCheckTests.cs
Normal file
66
tests/ScadaLink.Host.Tests/HealthCheckTests.cs
Normal file
@@ -0,0 +1,66 @@
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace ScadaLink.Host.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// WP-12: Tests for /health/ready endpoint.
|
||||
/// </summary>
|
||||
public class HealthCheckTests : IDisposable
|
||||
{
|
||||
private readonly List<IDisposable> _disposables = new();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var d in _disposables)
|
||||
{
|
||||
try { d.Dispose(); } catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HealthReady_Endpoint_ReturnsResponse()
|
||||
{
|
||||
var previousEnv = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT");
|
||||
try
|
||||
{
|
||||
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Central");
|
||||
|
||||
var factory = new WebApplicationFactory<Program>()
|
||||
.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureAppConfiguration((context, config) =>
|
||||
{
|
||||
config.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["ScadaLink:Node:NodeHostname"] = "localhost",
|
||||
["ScadaLink:Node:RemotingPort"] = "0",
|
||||
["ScadaLink:Cluster:SeedNodes:0"] = "akka.tcp://scadalink@localhost:2551",
|
||||
["ScadaLink:Cluster:SeedNodes:1"] = "akka.tcp://scadalink@localhost:2552",
|
||||
["ScadaLink:Database:SkipMigrations"] = "true",
|
||||
});
|
||||
});
|
||||
builder.UseSetting("ScadaLink:Node:Role", "Central");
|
||||
builder.UseSetting("ScadaLink:Database:SkipMigrations", "true");
|
||||
});
|
||||
_disposables.Add(factory);
|
||||
|
||||
var client = factory.CreateClient();
|
||||
_disposables.Add(client);
|
||||
|
||||
var response = await client.GetAsync("/health/ready");
|
||||
|
||||
// The endpoint exists and returns a status code.
|
||||
// With test infrastructure (no real DB), the database check may fail,
|
||||
// so we accept either 200 (Healthy) or 503 (Unhealthy).
|
||||
Assert.True(
|
||||
response.StatusCode == System.Net.HttpStatusCode.OK ||
|
||||
response.StatusCode == System.Net.HttpStatusCode.ServiceUnavailable,
|
||||
$"Expected 200 or 503, got {(int)response.StatusCode}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", previousEnv);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,17 @@ public class HostStartupTests : IDisposable
|
||||
var factory = new WebApplicationFactory<Program>()
|
||||
.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureAppConfiguration((_, config) =>
|
||||
{
|
||||
config.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["ScadaLink:Node:NodeHostname"] = "localhost",
|
||||
["ScadaLink:Node:RemotingPort"] = "0",
|
||||
["ScadaLink:Cluster:SeedNodes:0"] = "akka.tcp://scadalink@localhost:2551",
|
||||
["ScadaLink:Cluster:SeedNodes:1"] = "akka.tcp://scadalink@localhost:2552",
|
||||
["ScadaLink:Database:SkipMigrations"] = "true",
|
||||
});
|
||||
});
|
||||
builder.UseSetting("ScadaLink:Node:Role", "Central");
|
||||
builder.UseSetting("ScadaLink:Database:SkipMigrations", "true");
|
||||
});
|
||||
|
||||
@@ -13,9 +13,11 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Akka.TestKit.Xunit2" Version="1.5.62" />
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.5" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="Serilog" Version="4.3.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
</ItemGroup>
|
||||
|
||||
72
tests/ScadaLink.Host.Tests/SerilogTests.cs
Normal file
72
tests/ScadaLink.Host.Tests/SerilogTests.cs
Normal file
@@ -0,0 +1,72 @@
|
||||
using Serilog;
|
||||
using Serilog.Events;
|
||||
|
||||
namespace ScadaLink.Host.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// WP-14: Tests for Serilog structured logging with enriched properties.
|
||||
/// </summary>
|
||||
public class SerilogTests
|
||||
{
|
||||
[Fact]
|
||||
public void SerilogLogger_EnrichesWithNodeProperties()
|
||||
{
|
||||
var sink = new InMemorySink();
|
||||
|
||||
var logger = new LoggerConfiguration()
|
||||
.Enrich.WithProperty("SiteId", "TestSite")
|
||||
.Enrich.WithProperty("NodeHostname", "test-node1")
|
||||
.Enrich.WithProperty("NodeRole", "Site")
|
||||
.WriteTo.Sink(sink)
|
||||
.CreateLogger();
|
||||
|
||||
logger.Information("Test log message");
|
||||
|
||||
Assert.Single(sink.LogEvents);
|
||||
var logEvent = sink.LogEvents[0];
|
||||
|
||||
Assert.True(logEvent.Properties.ContainsKey("SiteId"));
|
||||
Assert.Equal("\"TestSite\"", logEvent.Properties["SiteId"].ToString());
|
||||
|
||||
Assert.True(logEvent.Properties.ContainsKey("NodeHostname"));
|
||||
Assert.Equal("\"test-node1\"", logEvent.Properties["NodeHostname"].ToString());
|
||||
|
||||
Assert.True(logEvent.Properties.ContainsKey("NodeRole"));
|
||||
Assert.Equal("\"Site\"", logEvent.Properties["NodeRole"].ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SerilogLogger_CentralRole_EnrichesSiteIdAsCentral()
|
||||
{
|
||||
var sink = new InMemorySink();
|
||||
|
||||
var logger = new LoggerConfiguration()
|
||||
.Enrich.WithProperty("SiteId", "central")
|
||||
.Enrich.WithProperty("NodeHostname", "central-node1")
|
||||
.Enrich.WithProperty("NodeRole", "Central")
|
||||
.WriteTo.Sink(sink)
|
||||
.CreateLogger();
|
||||
|
||||
logger.Warning("Central warning");
|
||||
|
||||
Assert.Single(sink.LogEvents);
|
||||
var logEvent = sink.LogEvents[0];
|
||||
|
||||
Assert.Equal(LogEventLevel.Warning, logEvent.Level);
|
||||
Assert.Equal("\"central\"", logEvent.Properties["SiteId"].ToString());
|
||||
Assert.Equal("\"Central\"", logEvent.Properties["NodeRole"].ToString());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Simple in-memory Serilog sink for testing.
|
||||
/// </summary>
|
||||
public class InMemorySink : Serilog.Core.ILogEventSink
|
||||
{
|
||||
public List<LogEvent> LogEvents { get; } = new();
|
||||
|
||||
public void Emit(LogEvent logEvent)
|
||||
{
|
||||
LogEvents.Add(logEvent);
|
||||
}
|
||||
}
|
||||
235
tests/ScadaLink.Host.Tests/StartupValidatorTests.cs
Normal file
235
tests/ScadaLink.Host.Tests/StartupValidatorTests.cs
Normal file
@@ -0,0 +1,235 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace ScadaLink.Host.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// WP-11: Tests for StartupValidator configuration validation.
|
||||
/// </summary>
|
||||
public class StartupValidatorTests
|
||||
{
|
||||
private static IConfiguration BuildConfig(Dictionary<string, string?> values)
|
||||
{
|
||||
return new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(values)
|
||||
.Build();
|
||||
}
|
||||
|
||||
private static Dictionary<string, string?> ValidCentralConfig() => new()
|
||||
{
|
||||
["ScadaLink:Node:Role"] = "Central",
|
||||
["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",
|
||||
["ScadaLink:Cluster:SeedNodes:1"] = "akka.tcp://scadalink@central-node2:8081",
|
||||
};
|
||||
|
||||
private static Dictionary<string, string?> ValidSiteConfig() => new()
|
||||
{
|
||||
["ScadaLink:Node:Role"] = "Site",
|
||||
["ScadaLink:Node:NodeHostname"] = "site-a-node1",
|
||||
["ScadaLink:Node:SiteId"] = "SiteA",
|
||||
["ScadaLink:Node:RemotingPort"] = "8082",
|
||||
["ScadaLink:Database:SiteDbPath"] = "./data/scadalink.db",
|
||||
["ScadaLink:Cluster:SeedNodes:0"] = "akka.tcp://scadalink@site-a-node1:8082",
|
||||
["ScadaLink:Cluster:SeedNodes:1"] = "akka.tcp://scadalink@site-a-node2:8082",
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void ValidCentralConfig_PassesValidation()
|
||||
{
|
||||
var config = BuildConfig(ValidCentralConfig());
|
||||
var ex = Record.Exception(() => StartupValidator.Validate(config));
|
||||
Assert.Null(ex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidSiteConfig_PassesValidation()
|
||||
{
|
||||
var config = BuildConfig(ValidSiteConfig());
|
||||
var ex = Record.Exception(() => StartupValidator.Validate(config));
|
||||
Assert.Null(ex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissingRole_FailsValidation()
|
||||
{
|
||||
var values = ValidCentralConfig();
|
||||
values.Remove("ScadaLink:Node:Role");
|
||||
var config = BuildConfig(values);
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => StartupValidator.Validate(config));
|
||||
Assert.Contains("Role must be 'Central' or 'Site'", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvalidRole_FailsValidation()
|
||||
{
|
||||
var values = ValidCentralConfig();
|
||||
values["ScadaLink:Node:Role"] = "Unknown";
|
||||
var config = BuildConfig(values);
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => StartupValidator.Validate(config));
|
||||
Assert.Contains("Role must be 'Central' or 'Site'", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmptyHostname_FailsValidation()
|
||||
{
|
||||
var values = ValidCentralConfig();
|
||||
values["ScadaLink:Node:NodeHostname"] = "";
|
||||
var config = BuildConfig(values);
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => StartupValidator.Validate(config));
|
||||
Assert.Contains("NodeHostname is required", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissingHostname_FailsValidation()
|
||||
{
|
||||
var values = ValidCentralConfig();
|
||||
values.Remove("ScadaLink:Node:NodeHostname");
|
||||
var config = BuildConfig(values);
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => StartupValidator.Validate(config));
|
||||
Assert.Contains("NodeHostname is required", ex.Message);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("0")]
|
||||
[InlineData("-1")]
|
||||
[InlineData("65536")]
|
||||
[InlineData("abc")]
|
||||
[InlineData("")]
|
||||
public void InvalidPort_FailsValidation(string port)
|
||||
{
|
||||
var values = ValidCentralConfig();
|
||||
values["ScadaLink:Node:RemotingPort"] = port;
|
||||
var config = BuildConfig(values);
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => StartupValidator.Validate(config));
|
||||
Assert.Contains("RemotingPort must be 1-65535", ex.Message);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("1")]
|
||||
[InlineData("8081")]
|
||||
[InlineData("65535")]
|
||||
public void ValidPort_PassesValidation(string port)
|
||||
{
|
||||
var values = ValidCentralConfig();
|
||||
values["ScadaLink:Node:RemotingPort"] = port;
|
||||
var config = BuildConfig(values);
|
||||
|
||||
var ex = Record.Exception(() => StartupValidator.Validate(config));
|
||||
Assert.Null(ex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Site_MissingSiteId_FailsValidation()
|
||||
{
|
||||
var values = ValidSiteConfig();
|
||||
values.Remove("ScadaLink:Node:SiteId");
|
||||
var config = BuildConfig(values);
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => StartupValidator.Validate(config));
|
||||
Assert.Contains("SiteId is required for Site nodes", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Central_MissingConfigurationDb_FailsValidation()
|
||||
{
|
||||
var values = ValidCentralConfig();
|
||||
values.Remove("ScadaLink:Database:ConfigurationDb");
|
||||
var config = BuildConfig(values);
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => StartupValidator.Validate(config));
|
||||
Assert.Contains("ConfigurationDb connection string required for Central", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Central_MissingMachineDataDb_FailsValidation()
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Central_MissingLdapServer_FailsValidation()
|
||||
{
|
||||
var values = ValidCentralConfig();
|
||||
values.Remove("ScadaLink:Security:LdapServer");
|
||||
var config = BuildConfig(values);
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => StartupValidator.Validate(config));
|
||||
Assert.Contains("LdapServer required for Central", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Central_MissingJwtSigningKey_FailsValidation()
|
||||
{
|
||||
var values = ValidCentralConfig();
|
||||
values.Remove("ScadaLink:Security:JwtSigningKey");
|
||||
var config = BuildConfig(values);
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => StartupValidator.Validate(config));
|
||||
Assert.Contains("JwtSigningKey required for Central", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Site_MissingSiteDbPath_FailsValidation()
|
||||
{
|
||||
var values = ValidSiteConfig();
|
||||
values.Remove("ScadaLink:Database:SiteDbPath");
|
||||
var config = BuildConfig(values);
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => StartupValidator.Validate(config));
|
||||
Assert.Contains("SiteDbPath required for Site nodes", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FewerThanTwoSeedNodes_FailsValidation()
|
||||
{
|
||||
var values = ValidCentralConfig();
|
||||
values.Remove("ScadaLink:Cluster:SeedNodes:1");
|
||||
var config = BuildConfig(values);
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => StartupValidator.Validate(config));
|
||||
Assert.Contains("SeedNodes must have at least 2 entries", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoSeedNodes_FailsValidation()
|
||||
{
|
||||
var values = ValidCentralConfig();
|
||||
values.Remove("ScadaLink:Cluster:SeedNodes:0");
|
||||
values.Remove("ScadaLink:Cluster:SeedNodes:1");
|
||||
var config = BuildConfig(values);
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => StartupValidator.Validate(config));
|
||||
Assert.Contains("SeedNodes must have at least 2 entries", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MultipleErrors_AllReported()
|
||||
{
|
||||
var values = new Dictionary<string, string?>
|
||||
{
|
||||
// Role is missing, hostname is missing, port is missing
|
||||
};
|
||||
var config = BuildConfig(values);
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => StartupValidator.Validate(config));
|
||||
Assert.Contains("Role must be 'Central' or 'Site'", ex.Message);
|
||||
Assert.Contains("NodeHostname is required", ex.Message);
|
||||
Assert.Contains("RemotingPort must be 1-65535", ex.Message);
|
||||
Assert.Contains("SeedNodes must have at least 2 entries", ex.Message);
|
||||
}
|
||||
}
|
||||
56
tests/ScadaLink.Host.Tests/WindowsServiceTests.cs
Normal file
56
tests/ScadaLink.Host.Tests/WindowsServiceTests.cs
Normal file
@@ -0,0 +1,56 @@
|
||||
using System.Reflection;
|
||||
|
||||
namespace ScadaLink.Host.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// WP-17: Tests for Windows Service support.
|
||||
/// Verifies UseWindowsService() is called in Program.cs.
|
||||
/// </summary>
|
||||
public class WindowsServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public void ProgramCs_CallsUseWindowsService()
|
||||
{
|
||||
var hostProjectDir = FindHostProjectDirectory();
|
||||
Assert.NotNull(hostProjectDir);
|
||||
|
||||
var programFile = Path.Combine(hostProjectDir, "Program.cs");
|
||||
Assert.True(File.Exists(programFile), "Program.cs not found");
|
||||
|
||||
var content = File.ReadAllText(programFile);
|
||||
|
||||
// Verify UseWindowsService() is called for both Central and Site paths
|
||||
var occurrences = content.Split("UseWindowsService()").Length - 1;
|
||||
Assert.True(occurrences >= 2,
|
||||
$"Expected UseWindowsService() to be called at least twice (Central and Site paths), found {occurrences} occurrence(s)");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HostProject_ReferencesWindowsServicesPackage()
|
||||
{
|
||||
var hostProjectDir = FindHostProjectDirectory();
|
||||
Assert.NotNull(hostProjectDir);
|
||||
|
||||
var csprojFile = Path.Combine(hostProjectDir, "ScadaLink.Host.csproj");
|
||||
Assert.True(File.Exists(csprojFile), "ScadaLink.Host.csproj not found");
|
||||
|
||||
var content = File.ReadAllText(csprojFile);
|
||||
Assert.Contains("Microsoft.Extensions.Hosting.WindowsServices", content);
|
||||
}
|
||||
|
||||
private static string? FindHostProjectDirectory()
|
||||
{
|
||||
var assemblyDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!;
|
||||
var dir = new DirectoryInfo(assemblyDir);
|
||||
|
||||
while (dir != null)
|
||||
{
|
||||
var hostPath = Path.Combine(dir.FullName, "src", "ScadaLink.Host");
|
||||
if (Directory.Exists(hostPath))
|
||||
return hostPath;
|
||||
dir = dir.Parent;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user