Files
scadalink-design/tests/ScadaLink.Host.Tests/HostStartupTests.cs
Joseph Doherty d38356efdb 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.
2026-03-16 19:50:59 -04:00

213 lines
8.2 KiB
C#

using System.Reflection;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using ScadaLink.ClusterInfrastructure;
using ScadaLink.Communication;
using ScadaLink.DataConnectionLayer;
using ScadaLink.ExternalSystemGateway;
using ScadaLink.HealthMonitoring;
using ScadaLink.Host;
using ScadaLink.NotificationService;
using ScadaLink.SiteEventLogging;
using ScadaLink.SiteRuntime;
using ScadaLink.StoreAndForward;
namespace ScadaLink.Host.Tests;
public class HostStartupTests : IDisposable
{
private readonly List<IDisposable> _disposables = new();
public void Dispose()
{
foreach (var d in _disposables)
{
try { d.Dispose(); } catch { /* best effort */ }
}
}
[Fact]
public void CentralRole_StartsWithoutError()
{
// WebApplicationFactory replays Program.Main, which reads config from files.
// Set the environment to Central so appsettings.Central.json is loaded,
// and set DOTNET_ENVIRONMENT before the factory creates the host.
var previousEnv = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT");
try
{
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Central");
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");
});
_disposables.Add(factory);
// Creating the server exercises the full DI container build and startup pipeline
var client = factory.CreateClient();
_disposables.Add(client);
// If we get here without exception, the central host started successfully
Assert.NotNull(client);
}
finally
{
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", previousEnv);
}
}
[Fact]
public void SiteRole_StartsWithoutError()
{
var builder = Microsoft.Extensions.Hosting.Host.CreateDefaultBuilder();
builder.ConfigureAppConfiguration(config =>
{
config.Sources.Clear();
config.AddInMemoryCollection(new Dictionary<string, string?>
{
["ScadaLink:Node:Role"] = "Site",
["ScadaLink:Node:NodeHostname"] = "test-site",
["ScadaLink:Node:SiteId"] = "TestSite",
["ScadaLink:Node:RemotingPort"] = "8082",
});
});
builder.ConfigureServices((context, services) =>
{
// Shared components
services.AddClusterInfrastructure();
services.AddCommunication();
services.AddHealthMonitoring();
services.AddExternalSystemGateway();
services.AddNotificationService();
// Site-only components
services.AddSiteRuntime();
services.AddDataConnectionLayer();
services.AddStoreAndForward();
services.AddSiteEventLogging();
// Options binding (mirrors Program.cs site path)
services.Configure<NodeOptions>(context.Configuration.GetSection("ScadaLink:Node"));
services.Configure<ClusterOptions>(context.Configuration.GetSection("ScadaLink:Cluster"));
services.Configure<DatabaseOptions>(context.Configuration.GetSection("ScadaLink:Database"));
services.Configure<CommunicationOptions>(context.Configuration.GetSection("ScadaLink:Communication"));
services.Configure<HealthMonitoringOptions>(context.Configuration.GetSection("ScadaLink:HealthMonitoring"));
services.Configure<NotificationOptions>(context.Configuration.GetSection("ScadaLink:Notification"));
services.Configure<LoggingOptions>(context.Configuration.GetSection("ScadaLink:Logging"));
services.Configure<DataConnectionOptions>(context.Configuration.GetSection("ScadaLink:DataConnection"));
services.Configure<StoreAndForwardOptions>(context.Configuration.GetSection("ScadaLink:StoreAndForward"));
services.Configure<SiteEventLogOptions>(context.Configuration.GetSection("ScadaLink:SiteEventLog"));
});
var host = builder.Build();
_disposables.Add(host);
// Build succeeds = DI container is valid and all services resolve
Assert.NotNull(host);
Assert.NotNull(host.Services);
}
[Fact]
public void SiteRole_DoesNotConfigureKestrel()
{
var builder = Microsoft.Extensions.Hosting.Host.CreateDefaultBuilder();
builder.ConfigureAppConfiguration(config =>
{
config.Sources.Clear();
config.AddInMemoryCollection(new Dictionary<string, string?>
{
["ScadaLink:Node:Role"] = "Site",
["ScadaLink:Node:NodeHostname"] = "test-site",
["ScadaLink:Node:SiteId"] = "TestSite",
["ScadaLink:Node:RemotingPort"] = "8082",
});
});
builder.ConfigureServices((context, services) =>
{
services.AddClusterInfrastructure();
services.AddCommunication();
services.AddHealthMonitoring();
services.AddExternalSystemGateway();
services.AddNotificationService();
services.AddSiteRuntime();
services.AddDataConnectionLayer();
services.AddStoreAndForward();
services.AddSiteEventLogging();
});
var host = builder.Build();
// Verify no Kestrel server or web host is registered.
// Host.CreateDefaultBuilder does not add Kestrel, so there should be no IServer.
var serverType = Type.GetType(
"Microsoft.AspNetCore.Hosting.Server.IServer, Microsoft.AspNetCore.Hosting.Server.Abstractions");
if (serverType != null)
{
var server = host.Services.GetService(serverType);
Assert.Null(server);
}
// Additionally verify no HTTP URLs are configured
var config = host.Services.GetRequiredService<IConfiguration>();
var urls = config["urls"] ?? config["ASPNETCORE_URLS"];
Assert.Null(urls);
host.Dispose();
}
[Fact]
public void HostProject_DoesNotUseConditionalCompilation()
{
var hostProjectDir = FindHostProjectDirectory();
Assert.NotNull(hostProjectDir);
var sourceFiles = Directory.GetFiles(hostProjectDir, "*.cs", SearchOption.TopDirectoryOnly);
Assert.NotEmpty(sourceFiles);
foreach (var file in sourceFiles)
{
var content = File.ReadAllText(file);
Assert.DoesNotContain("#if", content);
Assert.DoesNotContain("#ifdef", content);
Assert.DoesNotContain("#ifndef", content);
Assert.DoesNotContain("#elif", content);
Assert.DoesNotContain("#else", content);
Assert.DoesNotContain("#endif", content);
}
}
private static string? FindHostProjectDirectory()
{
// Walk up from the test assembly location to find the src directory
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;
}
}