513fa1aa16
Comment/XML-doc changes only: - add summaries to ~10k test methods, fixtures, fakes and helpers (+ <param> on theories, <returns> on async Task methods, <inheritdoc/> on interface implementations the checker can resolve) - strip ~2.6k project-tracking references (WP*, P*-*, row-NN, Task N, #NN, Component-0NN) from comments, keeping the explanatory text; reword false positives in comments only - real docs on fakes implementing BCL/library interfaces (IDisposable, ILogger, IOptionsMonitor, IAsyncLifetime) - reword the last 5 src/ false positives (CWE-1236, failures-1, year-0001, M365) Only non-comment edits: four one-line nested classes split across lines so members can carry docs. Build 0 errors / 0 warnings.
173 lines
8.6 KiB
C#
173 lines
8.6 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Proves the site pipeline actually MAPS the health endpoints — the half
|
|
/// <see cref="SiteHealthCheckTests"/> cannot cover, since that fixture builds the site container
|
|
/// from <see cref="SiteServiceRegistration"/> and never runs <c>Program</c>'s endpoint wiring.
|
|
/// Without this, deleting <c>app.MapZbHealth()</c> from the site branch would leave every
|
|
/// registration test green while site nodes served 404 to the overview dashboard.
|
|
/// <para>
|
|
/// Boots the real <c>Program</c> in the Site role. <c>Program</c> builds its own
|
|
/// <c>ConfigurationBuilder</c> before the web host exists, so its role and settings come from
|
|
/// PROCESS-WIDE environment variables (<c>SCADABRIDGE_CONFIG</c> selects appsettings.Site.json)
|
|
/// rather than from <c>WithWebHostBuilder</c> — which is why this fixture joins
|
|
/// <see cref="HostBootCollection"/> and restores every var it sets.
|
|
/// </para>
|
|
/// </summary>
|
|
[Collection(HostBootCollection.Name)]
|
|
public class SiteHealthEndpointTests : IDisposable
|
|
{
|
|
private readonly List<IDisposable> _disposables = new();
|
|
private readonly Dictionary<string, string?> _previousEnv = new(StringComparer.Ordinal);
|
|
private readonly string _tempDbPath;
|
|
private readonly string _tempAuditDbPath;
|
|
|
|
/// <summary>Initializes the test class with temporary database paths and the environment overrides a site node needs to boot.</summary>
|
|
public SiteHealthEndpointTests()
|
|
{
|
|
_tempDbPath = Path.Combine(Path.GetTempPath(), $"scadabridge_health_ep_{Guid.NewGuid()}.db");
|
|
_tempAuditDbPath = Path.Combine(Path.GetTempPath(), $"scadabridge_health_ep_audit_{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-<siteId>}
|
|
// 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);
|
|
// SqliteAuditWriterOptions.DatabasePath has no default
|
|
// and StartupValidator now requires it for Site nodes — same reason LocalDb__Path is
|
|
// overridden above (appsettings.Site.json's own default is CWD-relative and would
|
|
// otherwise litter the test working directory when SiteAuditBacklogReporter probes it).
|
|
SetEnv("AuditLog__SiteWriter__DatabasePath", _tempAuditDbPath);
|
|
}
|
|
|
|
private void SetEnv(string key, string? value)
|
|
{
|
|
_previousEnv[key] = Environment.GetEnvironmentVariable(key);
|
|
Environment.SetEnvironmentVariable(key, value);
|
|
}
|
|
|
|
/// <summary>Releases the resources held by this test class.</summary>
|
|
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 */ }
|
|
try { File.Delete(_tempAuditDbPath); } catch { /* best effort */ }
|
|
|
|
GC.SuppressFinalize(this);
|
|
}
|
|
|
|
private HttpClient CreateSiteClient()
|
|
{
|
|
var factory = new WebApplicationFactory<Program>()
|
|
.WithWebHostBuilder(builder =>
|
|
{
|
|
// The environment is left at WebApplicationFactory's Development default on
|
|
// purpose, so this fixture boots the site branch with ValidateOnBuild/
|
|
// ValidateScopes ON. It used to pin Production because the site graph carried two
|
|
// central-only scoped registrations it could not construct (ReconcileService →
|
|
// IDeploymentManagerRepository, AuditLogKpiSampleSource → IAuditLogRepository);
|
|
// deferred register row 50 moved both to central-only extensions
|
|
// (AddCentralCommunication / AddAuditLogCentralKpi), so the site container now
|
|
// validates clean and there is no reason to run it unvalidated.
|
|
|
|
// 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;
|
|
}
|
|
|
|
/// <summary>Verifies that a site node maps the ready health endpoint with the canonical JSON body.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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");
|
|
}
|
|
|
|
/// <summary>Verifies that a site node maps the active health endpoint carrying only the active-node check.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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());
|
|
}
|
|
|
|
/// <summary>Verifies that the site health endpoints are reachable anonymously.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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);
|
|
}
|
|
}
|
|
}
|