using System.Net; using System.Text.Json; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.DependencyInjection; namespace ZB.MOM.WW.ScadaBridge.Host.Tests; /// /// Proves the site pipeline actually MAPS the health endpoints — the half /// cannot cover, since that fixture builds the site container /// from and never runs Program's endpoint wiring. /// Without this, deleting app.MapZbHealth() from the site branch would leave every /// registration test green while site nodes served 404 to the overview dashboard. /// /// Boots the real Program in the Site role. Program builds its own /// ConfigurationBuilder before the web host exists, so its role and settings come from /// PROCESS-WIDE environment variables (SCADABRIDGE_CONFIG selects appsettings.Site.json) /// rather than from WithWebHostBuilder — which is why this fixture joins /// and restores every var it sets. /// /// [Collection(HostBootCollection.Name)] public class SiteHealthEndpointTests : IDisposable { private readonly List _disposables = new(); private readonly Dictionary _previousEnv = new(StringComparer.Ordinal); private readonly string _tempDbPath; public SiteHealthEndpointTests() { _tempDbPath = Path.Combine(Path.GetTempPath(), $"scadabridge_health_ep_{Guid.NewGuid()}.db"); // Whole-key env overrides, the sanctioned path: supplying GrpcPsk concretely makes the // pre-host secrets expander skip appsettings.Site.json's ${secret:SB-GRPC-PSK-site-1} // token, so this test needs no seeded secrets store. // // The remoting port is moved off the site default (8082) so this fixture cannot collide // with a locally running node, but it must be a REAL port and seed-nodes[0] must be this // node itself — StartupValidator enforces both before the host is built, and it runs // whether or not the Akka hosted service is later removed. Nothing binds it: the hosted // service is removed below. SetEnv("SCADABRIDGE_CONFIG", "Site"); SetEnv("ScadaBridge__Node__Role", "Site"); SetEnv("ScadaBridge__Node__SiteId", "TestSite"); SetEnv("ScadaBridge__Node__NodeHostname", "localhost"); SetEnv("ScadaBridge__Node__RemotingPort", "18082"); SetEnv("ScadaBridge__Cluster__SeedNodes__0", "akka.tcp://scadabridge@localhost:18082"); SetEnv("ScadaBridge__Cluster__SeedNodes__1", "akka.tcp://scadabridge@localhost:18085"); SetEnv("ScadaBridge__Communication__GrpcPsk", "test-psk-0123456789"); SetEnv("LocalDb__Path", _tempDbPath); } private void SetEnv(string key, string? value) { _previousEnv[key] = Environment.GetEnvironmentVariable(key); Environment.SetEnvironmentVariable(key, value); } public void Dispose() { foreach (var d in _disposables) { try { d.Dispose(); } catch { /* best effort */ } } foreach (var (key, value) in _previousEnv) { Environment.SetEnvironmentVariable(key, value); } try { File.Delete(_tempDbPath); } catch { /* best effort */ } GC.SuppressFinalize(this); } private HttpClient CreateSiteClient() { var factory = new WebApplicationFactory() .WithWebHostBuilder(builder => { // WebApplicationFactory defaults the environment to Development, which turns on // ValidateOnBuild/ValidateScopes. The site graph carries scoped registrations whose // dependencies are central-only (ReconcileService → IDeploymentManagerRepository, // AuditLogKpiSampleSource → IAuditLogRepository) and are never resolved on a site // node, so eager validation fails on a composition that runs fine in production. // That is a pre-existing property of the site container, not of the health wiring // under test — run this fixture the way a site node actually runs. builder.UseEnvironment("Production"); // ConfigureTestServices runs after the app's own registrations, so this drops the // hosted service that would form a real cluster while leaving the AkkaHostedService // singleton resolvable (the site pipeline resolves it for shutdown ordering). builder.ConfigureTestServices(AkkaHostedServiceRemover.RemoveAkkaHostedServiceOnly); }); _disposables.Add(factory); var client = factory.CreateClient(); _disposables.Add(client); return client; } [Fact] public async Task Site_MapsHealthReady_WithTheCanonicalJsonBody() { var response = await CreateSiteClient().GetAsync("/health/ready"); // Never 404. 200 vs 503 depends on cluster state this fixture does not form, so both are // accepted — what is asserted is that the tier is mapped and answers in canonical shape. Assert.NotEqual(HttpStatusCode.NotFound, response.StatusCode); Assert.True( response.StatusCode is HttpStatusCode.OK or HttpStatusCode.ServiceUnavailable, $"Expected 200 or 503, got {(int)response.StatusCode}"); using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); var entries = doc.RootElement.GetProperty("entries"); Assert.True(entries.TryGetProperty("akka-cluster", out _), "ready tier must carry akka-cluster"); Assert.True(entries.TryGetProperty("localdb", out _), "ready tier must carry localdb"); // Readiness must NOT depend on being the primary, or a healthy standby would read unready. Assert.False(entries.TryGetProperty("active-node", out _), "active-node belongs to the Active tier"); } [Fact] public async Task Site_MapsHealthActive_CarryingOnlyTheActiveNodeCheck() { var response = await CreateSiteClient().GetAsync("/health/active"); Assert.NotEqual(HttpStatusCode.NotFound, response.StatusCode); using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); var entries = doc.RootElement.GetProperty("entries"); Assert.Equal( new[] { "active-node" }, entries.EnumerateObject().Select(p => p.Name).ToArray()); } [Fact] public async Task Site_HealthEndpointsAreAnonymous() { // The dashboard authenticates nowhere. The site pipeline runs no authentication middleware // today, so this is a regression pin: adding one later must not silently close these. var client = CreateSiteClient(); foreach (var path in new[] { "/health/ready", "/health/active", "/healthz" }) { var response = await client.GetAsync(path); Assert.NotEqual(HttpStatusCode.Unauthorized, response.StatusCode); Assert.NotEqual(HttpStatusCode.Forbidden, response.StatusCode); Assert.NotEqual(HttpStatusCode.NotFound, response.StatusCode); } } }