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;
|
||||
}
|
||||
}
|
||||
84
tests/ScadaLink.IntegrationTests/AuditTransactionTests.cs
Normal file
84
tests/ScadaLink.IntegrationTests/AuditTransactionTests.cs
Normal file
@@ -0,0 +1,84 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ScadaLink.Commons.Entities.Security;
|
||||
using ScadaLink.Commons.Interfaces.Repositories;
|
||||
using ScadaLink.Commons.Interfaces.Services;
|
||||
using ScadaLink.ConfigurationDatabase;
|
||||
|
||||
namespace ScadaLink.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// WP-22: Audit transactional guarantee — entity change + audit log in same transaction.
|
||||
/// </summary>
|
||||
public class AuditTransactionTests : IClassFixture<ScadaLinkWebApplicationFactory>
|
||||
{
|
||||
private readonly ScadaLinkWebApplicationFactory _factory;
|
||||
|
||||
public AuditTransactionTests(ScadaLinkWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AuditLog_IsCommittedWithEntityChange_InSameTransaction()
|
||||
{
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var securityRepo = scope.ServiceProvider.GetRequiredService<ISecurityRepository>();
|
||||
var auditService = scope.ServiceProvider.GetRequiredService<IAuditService>();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
|
||||
|
||||
// Add a mapping and an audit log entry in the same unit of work
|
||||
var mapping = new LdapGroupMapping("test-group-audit", "Admin");
|
||||
await securityRepo.AddMappingAsync(mapping);
|
||||
|
||||
await auditService.LogAsync(
|
||||
user: "test-user",
|
||||
action: "Create",
|
||||
entityType: "LdapGroupMapping",
|
||||
entityId: "0", // ID not yet assigned
|
||||
entityName: "test-group-audit",
|
||||
afterState: new { Group = "test-group-audit", Role = "Admin" });
|
||||
|
||||
// Both should be in the change tracker before saving
|
||||
var trackedEntities = dbContext.ChangeTracker.Entries().Count(e => e.State == EntityState.Added);
|
||||
Assert.True(trackedEntities >= 2, "Both entity and audit log should be tracked before SaveChanges");
|
||||
|
||||
// Single SaveChangesAsync commits both
|
||||
await securityRepo.SaveChangesAsync();
|
||||
|
||||
// Verify both were persisted
|
||||
var mappings = await securityRepo.GetAllMappingsAsync();
|
||||
Assert.Contains(mappings, m => m.LdapGroupName == "test-group-audit");
|
||||
|
||||
var auditEntries = await dbContext.AuditLogEntries.ToListAsync();
|
||||
Assert.Contains(auditEntries, a => a.EntityName == "test-group-audit" && a.Action == "Create");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AuditLog_IsNotPersistedWhenSaveNotCalled()
|
||||
{
|
||||
// Create a separate scope so we have a fresh DbContext
|
||||
using var scope1 = _factory.Services.CreateScope();
|
||||
var securityRepo = scope1.ServiceProvider.GetRequiredService<ISecurityRepository>();
|
||||
var auditService = scope1.ServiceProvider.GetRequiredService<IAuditService>();
|
||||
|
||||
// Add entity + audit but do NOT call SaveChangesAsync
|
||||
var mapping = new LdapGroupMapping("orphan-group", "Design");
|
||||
await securityRepo.AddMappingAsync(mapping);
|
||||
await auditService.LogAsync("test", "Create", "LdapGroupMapping", "0", "orphan-group", null);
|
||||
|
||||
// Dispose scope without saving — simulates a failed transaction
|
||||
scope1.Dispose();
|
||||
|
||||
// In a new scope, verify nothing was persisted
|
||||
using var scope2 = _factory.Services.CreateScope();
|
||||
var securityRepo2 = scope2.ServiceProvider.GetRequiredService<ISecurityRepository>();
|
||||
var dbContext2 = scope2.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
|
||||
|
||||
var mappings = await securityRepo2.GetAllMappingsAsync();
|
||||
Assert.DoesNotContain(mappings, m => m.LdapGroupName == "orphan-group");
|
||||
|
||||
var auditEntries = await dbContext2.AuditLogEntries.ToListAsync();
|
||||
Assert.DoesNotContain(auditEntries, a => a.EntityName == "orphan-group");
|
||||
}
|
||||
}
|
||||
132
tests/ScadaLink.IntegrationTests/AuthFlowTests.cs
Normal file
132
tests/ScadaLink.IntegrationTests/AuthFlowTests.cs
Normal file
@@ -0,0 +1,132 @@
|
||||
using System.Net;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ScadaLink.CentralUI.Auth;
|
||||
using ScadaLink.Security;
|
||||
|
||||
namespace ScadaLink.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// WP-22: Auth flow integration tests.
|
||||
/// Tests that require a running LDAP server are marked with Integration trait.
|
||||
/// </summary>
|
||||
public class AuthFlowTests : IClassFixture<ScadaLinkWebApplicationFactory>
|
||||
{
|
||||
private readonly ScadaLinkWebApplicationFactory _factory;
|
||||
|
||||
public AuthFlowTests(ScadaLinkWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoginEndpoint_WithEmptyCredentials_RedirectsToLoginWithError()
|
||||
{
|
||||
var client = _factory.CreateClient(new Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactoryClientOptions
|
||||
{
|
||||
AllowAutoRedirect = false
|
||||
});
|
||||
|
||||
var content = new FormUrlEncodedContent(new[]
|
||||
{
|
||||
new KeyValuePair<string, string>("username", ""),
|
||||
new KeyValuePair<string, string>("password", "")
|
||||
});
|
||||
|
||||
var response = await client.PostAsync("/auth/login", content);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
|
||||
var location = response.Headers.Location?.ToString() ?? "";
|
||||
Assert.Contains("/login", location);
|
||||
Assert.Contains("error", location, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LogoutEndpoint_ClearsCookieAndRedirects()
|
||||
{
|
||||
var client = _factory.CreateClient(new Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactoryClientOptions
|
||||
{
|
||||
AllowAutoRedirect = false
|
||||
});
|
||||
|
||||
var response = await client.PostAsync("/auth/logout", null);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
|
||||
var location = response.Headers.Location?.ToString() ?? "";
|
||||
Assert.Contains("/login", location);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JwtTokenService_GenerateAndValidate_RoundTrips()
|
||||
{
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var jwtService = scope.ServiceProvider.GetRequiredService<JwtTokenService>();
|
||||
|
||||
var token = jwtService.GenerateToken(
|
||||
displayName: "Test User",
|
||||
username: "testuser",
|
||||
roles: new[] { "Admin", "Design" },
|
||||
permittedSiteIds: null);
|
||||
|
||||
Assert.NotNull(token);
|
||||
|
||||
var principal = jwtService.ValidateToken(token);
|
||||
Assert.NotNull(principal);
|
||||
|
||||
var displayName = principal!.FindFirst(JwtTokenService.DisplayNameClaimType)?.Value;
|
||||
var username = principal.FindFirst(JwtTokenService.UsernameClaimType)?.Value;
|
||||
var roles = principal.FindAll(JwtTokenService.RoleClaimType).Select(c => c.Value).ToList();
|
||||
|
||||
Assert.Equal("Test User", displayName);
|
||||
Assert.Equal("testuser", username);
|
||||
Assert.Contains("Admin", roles);
|
||||
Assert.Contains("Design", roles);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JwtTokenService_WithSiteScopes_IncludesSiteIdClaims()
|
||||
{
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var jwtService = scope.ServiceProvider.GetRequiredService<JwtTokenService>();
|
||||
|
||||
var token = jwtService.GenerateToken(
|
||||
displayName: "Deployer",
|
||||
username: "deployer1",
|
||||
roles: new[] { "Deployment" },
|
||||
permittedSiteIds: new[] { "1", "3" });
|
||||
|
||||
var principal = jwtService.ValidateToken(token);
|
||||
Assert.NotNull(principal);
|
||||
|
||||
var siteIds = principal!.FindAll(JwtTokenService.SiteIdClaimType).Select(c => c.Value).ToList();
|
||||
Assert.Contains("1", siteIds);
|
||||
Assert.Contains("3", siteIds);
|
||||
}
|
||||
|
||||
[Trait("Category", "Integration")]
|
||||
[Fact(Skip = "Requires running GLAuth LDAP server (Docker). Run with: docker compose -f infra/docker-compose.yml up -d glauth")]
|
||||
public async Task LoginEndpoint_WithValidLdapCredentials_SetsCookieAndRedirects()
|
||||
{
|
||||
// This test requires the GLAuth test LDAP server running on localhost:3893
|
||||
var client = _factory.CreateClient(new Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactoryClientOptions
|
||||
{
|
||||
AllowAutoRedirect = false
|
||||
});
|
||||
|
||||
var content = new FormUrlEncodedContent(new[]
|
||||
{
|
||||
new KeyValuePair<string, string>("username", "admin"),
|
||||
new KeyValuePair<string, string>("password", "admin")
|
||||
});
|
||||
|
||||
var response = await client.PostAsync("/auth/login", content);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
|
||||
var location = response.Headers.Location?.ToString() ?? "";
|
||||
Assert.Equal("/", location);
|
||||
|
||||
// Verify auth cookie was set
|
||||
var setCookieHeader = response.Headers.GetValues("Set-Cookie").FirstOrDefault();
|
||||
Assert.NotNull(setCookieHeader);
|
||||
Assert.Contains(CookieAuthenticationStateProvider.AuthCookieName, setCookieHeader);
|
||||
}
|
||||
}
|
||||
30
tests/ScadaLink.IntegrationTests/ReadinessTests.cs
Normal file
30
tests/ScadaLink.IntegrationTests/ReadinessTests.cs
Normal file
@@ -0,0 +1,30 @@
|
||||
using System.Net;
|
||||
|
||||
namespace ScadaLink.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// WP-22: Readiness gating — /health/ready endpoint returns status code.
|
||||
/// </summary>
|
||||
public class ReadinessTests : IClassFixture<ScadaLinkWebApplicationFactory>
|
||||
{
|
||||
private readonly ScadaLinkWebApplicationFactory _factory;
|
||||
|
||||
public ReadinessTests(ScadaLinkWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HealthReady_ReturnsSuccessStatusCode()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/health/ready");
|
||||
|
||||
// The endpoint should exist and return 200 OK (or 503 if not ready yet).
|
||||
// For now, just verify the endpoint exists and returns a valid HTTP response.
|
||||
Assert.True(
|
||||
response.StatusCode == HttpStatusCode.OK || response.StatusCode == HttpStatusCode.ServiceUnavailable,
|
||||
$"Expected 200 or 503 but got {response.StatusCode}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.5" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.5" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../src/ScadaLink.Host/ScadaLink.Host.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,100 @@
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using ScadaLink.ConfigurationDatabase;
|
||||
using ScadaLink.Host.Actors;
|
||||
|
||||
namespace ScadaLink.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Shared WebApplicationFactory for integration tests.
|
||||
/// Replaces SQL Server with an in-memory database and skips migrations.
|
||||
/// Removes AkkaHostedService to avoid DNS resolution issues in test environments.
|
||||
/// Uses environment variables for config since Program.cs reads them in the initial ConfigurationBuilder
|
||||
/// before WebApplicationFactory can inject settings.
|
||||
/// </summary>
|
||||
public class ScadaLinkWebApplicationFactory : WebApplicationFactory<Program>
|
||||
{
|
||||
/// <summary>
|
||||
/// Environment variables that were set by this factory, to be cleaned up on dispose.
|
||||
/// </summary>
|
||||
private readonly Dictionary<string, string?> _previousEnvVars = new();
|
||||
|
||||
public ScadaLinkWebApplicationFactory()
|
||||
{
|
||||
// The initial ConfigurationBuilder in Program.cs reads env vars with AddEnvironmentVariables().
|
||||
// The env var format uses __ as section separator.
|
||||
var envVars = new Dictionary<string, string>
|
||||
{
|
||||
["DOTNET_ENVIRONMENT"] = "Development",
|
||||
["ScadaLink__Node__Role"] = "Central",
|
||||
["ScadaLink__Node__NodeHostname"] = "localhost",
|
||||
["ScadaLink__Node__RemotingPort"] = "8081",
|
||||
["ScadaLink__Cluster__SeedNodes__0"] = "akka.tcp://scadalink@localhost:8081",
|
||||
["ScadaLink__Cluster__SeedNodes__1"] = "akka.tcp://scadalink@localhost:8082",
|
||||
["ScadaLink__Database__ConfigurationDb"] = "Server=localhost;Database=ScadaLink_Test;TrustServerCertificate=True",
|
||||
["ScadaLink__Database__MachineDataDb"] = "Server=localhost;Database=ScadaLink_MachineData_Test;TrustServerCertificate=True",
|
||||
["ScadaLink__Database__SkipMigrations"] = "true",
|
||||
["ScadaLink__Security__JwtSigningKey"] = "integration-test-signing-key-must-be-at-least-32-chars-long",
|
||||
["ScadaLink__Security__LdapServer"] = "localhost",
|
||||
["ScadaLink__Security__LdapPort"] = "3893",
|
||||
["ScadaLink__Security__LdapUseTls"] = "false",
|
||||
["ScadaLink__Security__AllowInsecureLdap"] = "true",
|
||||
["ScadaLink__Security__LdapSearchBase"] = "dc=scadalink,dc=local",
|
||||
};
|
||||
|
||||
foreach (var (key, value) in envVars)
|
||||
{
|
||||
_previousEnvVars[key] = Environment.GetEnvironmentVariable(key);
|
||||
Environment.SetEnvironmentVariable(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
builder.UseEnvironment("Development");
|
||||
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
// Remove ALL DbContext and EF-related service registrations to avoid dual-provider conflict.
|
||||
// AddDbContext<> with UseSqlServer registers many internal services. We must remove them all.
|
||||
var descriptorsToRemove = services
|
||||
.Where(d =>
|
||||
d.ServiceType == typeof(DbContextOptions<ScadaLinkDbContext>) ||
|
||||
d.ServiceType == typeof(DbContextOptions) ||
|
||||
d.ServiceType == typeof(ScadaLinkDbContext) ||
|
||||
d.ServiceType.FullName?.Contains("EntityFrameworkCore") == true)
|
||||
.ToList();
|
||||
foreach (var d in descriptorsToRemove)
|
||||
services.Remove(d);
|
||||
|
||||
// Add in-memory database as sole provider
|
||||
services.AddDbContext<ScadaLinkDbContext>(options =>
|
||||
options.UseInMemoryDatabase($"ScadaLink_IntegrationTests_{Guid.NewGuid()}"));
|
||||
|
||||
// Remove AkkaHostedService to avoid Akka.NET remoting DNS resolution in tests.
|
||||
// It registers as both a singleton and a hosted service via factory.
|
||||
var akkaDescriptors = services
|
||||
.Where(d =>
|
||||
d.ServiceType == typeof(AkkaHostedService) ||
|
||||
(d.ServiceType == typeof(IHostedService) && d.ImplementationFactory != null))
|
||||
.ToList();
|
||||
foreach (var d in akkaDescriptors)
|
||||
services.Remove(d);
|
||||
});
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
base.Dispose(disposing);
|
||||
if (disposing)
|
||||
{
|
||||
foreach (var (key, previousValue) in _previousEnvVars)
|
||||
{
|
||||
Environment.SetEnvironmentVariable(key, previousValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
128
tests/ScadaLink.IntegrationTests/StartupValidationTests.cs
Normal file
128
tests/ScadaLink.IntegrationTests/StartupValidationTests.cs
Normal file
@@ -0,0 +1,128 @@
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
|
||||
namespace ScadaLink.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// WP-22: Startup validation — missing required config fails with clear error.
|
||||
/// Tests the StartupValidator that runs on boot.
|
||||
///
|
||||
/// Note: These tests temporarily set environment variables because Program.cs reads
|
||||
/// configuration from env vars in the initial ConfigurationBuilder (before WebApplicationFactory
|
||||
/// can inject settings). Each test saves/restores env vars to avoid interference.
|
||||
/// </summary>
|
||||
public class StartupValidationTests
|
||||
{
|
||||
[Fact]
|
||||
public void MissingRole_ThrowsInvalidOperationException()
|
||||
{
|
||||
// Set all required config EXCEPT Role
|
||||
using var env = new TempEnvironment(new Dictionary<string, string>
|
||||
{
|
||||
["DOTNET_ENVIRONMENT"] = "Development",
|
||||
["ScadaLink__Node__NodeHostname"] = "localhost",
|
||||
["ScadaLink__Node__RemotingPort"] = "8081",
|
||||
["ScadaLink__Cluster__SeedNodes__0"] = "akka.tcp://scadalink@localhost:8081",
|
||||
["ScadaLink__Cluster__SeedNodes__1"] = "akka.tcp://scadalink@localhost:8082",
|
||||
});
|
||||
|
||||
var factory = new WebApplicationFactory<Program>();
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => factory.CreateClient());
|
||||
Assert.Contains("Role", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
factory.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissingJwtSigningKey_ForCentral_ThrowsInvalidOperationException()
|
||||
{
|
||||
using var env = new TempEnvironment(new Dictionary<string, string>
|
||||
{
|
||||
["DOTNET_ENVIRONMENT"] = "Development",
|
||||
["ScadaLink__Node__Role"] = "Central",
|
||||
["ScadaLink__Node__NodeHostname"] = "localhost",
|
||||
["ScadaLink__Node__RemotingPort"] = "8081",
|
||||
["ScadaLink__Cluster__SeedNodes__0"] = "akka.tcp://scadalink@localhost:8081",
|
||||
["ScadaLink__Cluster__SeedNodes__1"] = "akka.tcp://scadalink@localhost:8082",
|
||||
["ScadaLink__Database__ConfigurationDb"] = "Server=x;Database=x",
|
||||
["ScadaLink__Database__MachineDataDb"] = "Server=x;Database=x",
|
||||
["ScadaLink__Security__LdapServer"] = "localhost",
|
||||
// Deliberately missing JwtSigningKey
|
||||
});
|
||||
|
||||
var factory = new WebApplicationFactory<Program>();
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => factory.CreateClient());
|
||||
Assert.Contains("JwtSigningKey", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
factory.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CentralRole_StartsSuccessfully_WithValidConfig()
|
||||
{
|
||||
using var factory = new ScadaLinkWebApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
Assert.NotNull(client);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Helper to temporarily set environment variables and restore them on dispose.
|
||||
/// Clears all ScadaLink__ vars first to ensure a clean slate.
|
||||
/// </summary>
|
||||
private sealed class TempEnvironment : IDisposable
|
||||
{
|
||||
private readonly Dictionary<string, string?> _previousValues = new();
|
||||
|
||||
/// <summary>
|
||||
/// All ScadaLink env vars that might be set by other tests/factories.
|
||||
/// </summary>
|
||||
private static readonly string[] KnownKeys =
|
||||
{
|
||||
"DOTNET_ENVIRONMENT",
|
||||
"ScadaLink__Node__Role",
|
||||
"ScadaLink__Node__NodeHostname",
|
||||
"ScadaLink__Node__RemotingPort",
|
||||
"ScadaLink__Node__SiteId",
|
||||
"ScadaLink__Cluster__SeedNodes__0",
|
||||
"ScadaLink__Cluster__SeedNodes__1",
|
||||
"ScadaLink__Database__ConfigurationDb",
|
||||
"ScadaLink__Database__MachineDataDb",
|
||||
"ScadaLink__Database__SkipMigrations",
|
||||
"ScadaLink__Security__JwtSigningKey",
|
||||
"ScadaLink__Security__LdapServer",
|
||||
"ScadaLink__Security__LdapPort",
|
||||
"ScadaLink__Security__LdapUseTls",
|
||||
"ScadaLink__Security__AllowInsecureLdap",
|
||||
"ScadaLink__Security__LdapSearchBase",
|
||||
};
|
||||
|
||||
public TempEnvironment(Dictionary<string, string> varsToSet)
|
||||
{
|
||||
// Save and clear all known keys
|
||||
foreach (var key in KnownKeys)
|
||||
{
|
||||
_previousValues[key] = Environment.GetEnvironmentVariable(key);
|
||||
Environment.SetEnvironmentVariable(key, null);
|
||||
}
|
||||
|
||||
// Set the requested vars
|
||||
foreach (var (key, value) in varsToSet)
|
||||
{
|
||||
if (!_previousValues.ContainsKey(key))
|
||||
_previousValues[key] = Environment.GetEnvironmentVariable(key);
|
||||
Environment.SetEnvironmentVariable(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var (key, previousValue) in _previousValues)
|
||||
{
|
||||
Environment.SetEnvironmentVariable(key, previousValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
5
tests/ScadaLink.IntegrationTests/xunit.runner.json
Normal file
5
tests/ScadaLink.IntegrationTests/xunit.runner.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
|
||||
"parallelizeAssembly": false,
|
||||
"parallelizeTestCollections": false
|
||||
}
|
||||
Reference in New Issue
Block a user