using System.Reflection; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using ZB.MOM.WW.ScadaBridge.Host; namespace ZB.MOM.WW.ScadaBridge.Host.Tests; [Collection(HostBootCollection.Name)] public class HostStartupTests : IDisposable { private readonly List _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"); // Host-003: connection strings are externalised; supply them via env vars. using var dbEnv = new CentralDbTestEnvironment(); try { Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Central"); var factory = new WebApplicationFactory() .WithWebHostBuilder(builder => { builder.ConfigureAppConfiguration((_, config) => { config.AddInMemoryCollection(new Dictionary { ["ScadaBridge:Node:NodeName"] = "central-a", ["ScadaBridge:Node:NodeHostname"] = "localhost", ["ScadaBridge:Node:RemotingPort"] = "0", ["ScadaBridge:Cluster:SeedNodes:0"] = "akka.tcp://scadabridge@localhost:2551", ["ScadaBridge:Cluster:SeedNodes:1"] = "akka.tcp://scadabridge@localhost:2552", ["ScadaBridge:Database:SkipMigrations"] = "true", }); }); builder.UseSetting("ScadaBridge:Node:Role", "Central"); builder.UseSetting("ScadaBridge: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 = WebApplication.CreateBuilder(); builder.Configuration.Sources.Clear(); builder.Configuration.AddInMemoryCollection(new Dictionary { ["ScadaBridge:Node:Role"] = "Site", ["ScadaBridge:Node:NodeName"] = "node-a", ["ScadaBridge:Node:NodeHostname"] = "test-site", ["ScadaBridge:Node:SiteId"] = "TestSite", ["ScadaBridge:Node:RemotingPort"] = "0", ["ScadaBridge:Node:GrpcPort"] = "0", }); builder.Services.AddGrpc(); builder.Services.AddSingleton(); SiteServiceRegistration.Configure(builder.Services, builder.Configuration); // Remove AkkaHostedService from running AkkaHostedServiceRemover.RemoveAkkaHostedServiceOnly(builder.Services); var app = builder.Build(); _disposables.Add(app); // Build succeeds = DI container is valid and all services resolve Assert.NotNull(app); Assert.NotNull(app.Services); } [Fact] public void SiteRole_ConfiguresKestrelForGrpc() { var builder = WebApplication.CreateBuilder(); builder.Configuration.Sources.Clear(); builder.Configuration.AddInMemoryCollection(new Dictionary { ["ScadaBridge:Node:Role"] = "Site", ["ScadaBridge:Node:NodeName"] = "node-a", ["ScadaBridge:Node:NodeHostname"] = "test-site", ["ScadaBridge:Node:SiteId"] = "TestSite", ["ScadaBridge:Node:RemotingPort"] = "0", ["ScadaBridge:Node:GrpcPort"] = "0", }); builder.WebHost.ConfigureKestrel(options => { options.ListenAnyIP(0, listenOptions => { listenOptions.Protocols = Microsoft.AspNetCore.Server.Kestrel.Core.HttpProtocols.Http2; }); }); builder.Services.AddGrpc(); builder.Services.AddSingleton(); SiteServiceRegistration.Configure(builder.Services, builder.Configuration); // Remove AkkaHostedService from running AkkaHostedServiceRemover.RemoveAkkaHostedServiceOnly(builder.Services); var app = builder.Build(); // Verify Kestrel IS configured (site now hosts gRPC via WebApplicationBuilder) var serverType = Type.GetType( "Microsoft.AspNetCore.Hosting.Server.IServer, Microsoft.AspNetCore.Hosting.Server.Abstractions"); if (serverType != null) { var server = app.Services.GetService(serverType); Assert.NotNull(server); } (app as IDisposable)?.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); } } [Fact] public void Program_RegistersUnhandledExceptionExitHandler() { // ScadaBridge#34: in a container this process is PID 1, and Linux ignores the // SIGABRT the runtime's crash path raises against PID 1 — an unhandled boot // exception printed its banner and then spun the main thread at 100% CPU with // the container still `running`, so the restart policy never fired. Program.cs // therefore registers an AppDomain.UnhandledException handler that exits via // the exit() syscall (which PID 1 CAN perform) before anything can throw. // Behavioural coverage needs a real crashed process (see the compose comment // and the issue's live reproductions); this pins the handler's existence so a // refactor cannot silently reopen the wedge. var hostProjectDir = FindHostProjectDirectory(); Assert.NotNull(hostProjectDir); var program = File.ReadAllText(Path.Combine(hostProjectDir!, "Program.cs")); Assert.Contains("AppDomain.CurrentDomain.UnhandledException +=", program); Assert.Contains("Environment.Exit(134)", program); // The registration must precede the first statement that can throw — the // configuration build is the earliest (appsettings.json is non-optional). var handlerAt = program.IndexOf("AppDomain.CurrentDomain.UnhandledException +=", StringComparison.Ordinal); var configAt = program.IndexOf("new ConfigurationBuilder()", StringComparison.Ordinal); Assert.True(handlerAt >= 0 && configAt >= 0 && handlerAt < configAt, "the UnhandledException exit handler must be registered before the configuration build"); } 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", "ZB.MOM.WW.ScadaBridge.Host"); if (Directory.Exists(hostPath)) return hostPath; dir = dir.Parent; } return null; } }