docs(tests): fix CommentChecker findings across tests/ — 13,392 → 0 (repo-wide scan clean)

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.
This commit is contained in:
Joseph Doherty
2026-10-07 15:05:49 -04:00
parent 0ffd4bdbea
commit 513fa1aa16
998 changed files with 17896 additions and 2395 deletions
@@ -22,6 +22,8 @@ public class CentralActorPathTests : IAsyncLifetime
private string? _previousEnv;
private CentralDbTestEnvironment? _dbEnv;
/// <summary>Initializes the fixture before the tests run.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public Task InitializeAsync()
{
_previousEnv = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT");
@@ -49,7 +51,7 @@ public class CentralActorPathTests : IAsyncLifetime
["ScadaBridge:Cluster:MinNrOfMembers"] = "1",
["ScadaBridge:Database:SkipMigrations"] = "true",
["ScadaBridge:Security:JwtSigningKey"] = "test-signing-key-must-be-at-least-32-chars-long!",
// Task 1.4: LDAP settings nest under Security:Ldap (shared LdapOptions).
// LDAP settings nest under Security:Ldap (shared LdapOptions).
// ServiceAccountDn is now required by the library's LdapOptionsValidator
// (ValidateOnStart), so it must be present for the host to start.
["ScadaBridge:Security:Ldap:Server"] = "localhost",
@@ -88,6 +90,8 @@ public class CentralActorPathTests : IAsyncLifetime
return Task.CompletedTask;
}
/// <summary>Releases the fixture after the tests run.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task DisposeAsync()
{
// try/finally: a throwing host teardown must not strand the process-wide
@@ -107,38 +111,56 @@ public class CentralActorPathTests : IAsyncLifetime
await Task.CompletedTask;
}
/// <summary>Verifies that the dead letter monitor actor exists on a central node.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task CentralActors_DeadLetterMonitor_Exists()
=> await AssertActorExists("/user/dead-letter-monitor");
/// <summary>Verifies that the central communication actor exists on a central node.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task CentralActors_CentralCommunication_Exists()
=> await AssertActorExists("/user/central-communication");
/// <summary>Verifies that the management actor exists on a central node.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task CentralActors_Management_Exists()
=> await AssertActorExists("/user/management");
/// <summary>Verifies that the notification outbox singleton exists on a central node.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task CentralActors_NotificationOutboxSingleton_Exists()
=> await AssertActorExists("/user/notification-outbox-singleton");
/// <summary>Verifies that the notification outbox proxy exists on a central node.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task CentralActors_NotificationOutboxProxy_Exists()
=> await AssertActorExists("/user/notification-outbox-proxy");
/// <summary>Verifies that the audit log purge singleton exists on a central node.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task CentralActors_AuditLogPurgeSingleton_Exists()
=> await AssertActorExists("/user/audit-log-purge-singleton");
/// <summary>Verifies that the audit log purge proxy exists on a central node.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task CentralActors_AuditLogPurgeProxy_Exists()
=> await AssertActorExists("/user/audit-log-purge-proxy");
/// <summary>Verifies that the site audit reconciliation singleton exists on a central node.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task CentralActors_SiteAuditReconciliationSingleton_Exists()
=> await AssertActorExists("/user/site-audit-reconciliation-singleton");
/// <summary>Verifies that the site audit reconciliation proxy exists on a central node.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task CentralActors_SiteAuditReconciliationProxy_Exists()
=> await AssertActorExists("/user/site-audit-reconciliation-proxy");
@@ -165,6 +187,8 @@ public class SiteActorPathTests : IAsyncLifetime
private string _tempDbPath = null!;
private string _tempLocalDbPath = null!;
/// <summary>Initializes the fixture before the tests run.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task InitializeAsync()
{
_tempDbPath = Path.Combine(Path.GetTempPath(), $"scadabridge_actor_test_{Guid.NewGuid()}.db");
@@ -206,6 +230,8 @@ public class SiteActorPathTests : IAsyncLifetime
_actorSystem = akkaService.ActorSystem;
}
/// <summary>Releases the fixture after the tests run.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task DisposeAsync()
{
if (_host != null)
@@ -217,22 +243,32 @@ public class SiteActorPathTests : IAsyncLifetime
try { File.Delete(_tempLocalDbPath); } catch { /* best effort */ }
}
/// <summary>Verifies that the dead letter monitor actor exists on a site node.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SiteActors_DeadLetterMonitor_Exists()
=> await AssertActorExists("/user/dead-letter-monitor");
/// <summary>Verifies that the data connection layer manager actor exists on a site node.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SiteActors_DclManager_Exists()
=> await AssertActorExists("/user/dcl-manager");
/// <summary>Verifies that the deployment manager singleton exists on a site node.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SiteActors_DeploymentManagerSingleton_Exists()
=> await AssertActorExists("/user/deployment-manager-singleton");
/// <summary>Verifies that the deployment manager proxy exists on a site node.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SiteActors_DeploymentManagerProxy_Exists()
=> await AssertActorExists("/user/deployment-manager-proxy");
/// <summary>Verifies that the site communication actor exists on a site node.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SiteActors_SiteCommunication_Exists()
=> await AssertActorExists("/user/site-communication");
@@ -20,6 +20,7 @@ namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
/// </summary>
public sealed class ActorSystemBridgeTests
{
/// <summary>Verifies that actor system resolves null before hosted service starts.</summary>
[Fact]
public void ActorSystem_ResolvesNull_BeforeHostedServiceStarts()
{
@@ -4,17 +4,19 @@ using Akka.Configuration;
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
/// <summary>
/// WP-13: Tests for Akka.NET actor system bootstrap.
/// Tests for Akka.NET actor system bootstrap.
/// </summary>
public class AkkaBootstrapTests : IDisposable
{
private ActorSystem? _actorSystem;
/// <summary>Releases the resources held by this test class.</summary>
public void Dispose()
{
_actorSystem?.Dispose();
}
/// <summary>Verifies that the actor system is created with the cluster configuration.</summary>
[Fact]
public void ActorSystem_CreatesWithClusterConfig()
{
@@ -45,6 +47,7 @@ akka {
Assert.Equal("scadabridge-test", _actorSystem.Name);
}
/// <summary>Verifies that the HOCON configuration includes coordinated shutdown.</summary>
[Fact]
public void ActorSystem_HoconConfig_IncludesCoordinatedShutdown()
{
@@ -21,6 +21,8 @@ public sealed class AkkaHealthReportTransportTests
{
private sealed class AckActor : ReceiveActor
{
/// <summary>Initializes the actor to acknowledge every health report with the given verdict.</summary>
/// <param name="accepted">Whether the acknowledgement reports the report as accepted.</param>
public AckActor(bool accepted) => Receive<SiteHealthReport>(r =>
Sender.Tell(new SiteHealthReportAck(
r.SiteId, r.SequenceNumber, accepted, accepted ? null : "rejected by test")));
@@ -55,6 +57,8 @@ public sealed class AkkaHealthReportTransportTests
return svc;
}
/// <summary>Verifies that sending completes when central acknowledges the report as accepted.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SendAsync_AckedAccepted_Completes()
{
@@ -68,6 +72,8 @@ public sealed class AkkaHealthReportTransportTests
finally { await system.Terminate(); }
}
/// <summary>Verifies that sending throws when central acknowledges the report as not accepted.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SendAsync_AckedNotAccepted_Throws()
{
@@ -82,6 +88,8 @@ public sealed class AkkaHealthReportTransportTests
finally { await system.Terminate(); }
}
/// <summary>Verifies that sending throws when no actor responds.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SendAsync_NoResponder_Throws()
{
@@ -20,7 +20,7 @@ using ZB.MOM.WW.ScadaBridge.StoreAndForward;
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
/// <summary>
/// Bundle E (M2 Task E1) — verifies the Audit Log (#23) DI surface is wired
/// Verifies the Audit Log DI surface is wired
/// into both composition roots and that the HOCON document emitted by
/// <see cref="AkkaHostedService.BuildHocon"/> includes the dedicated
/// <c>audit-telemetry-dispatcher</c> the site telemetry actor binds to.
@@ -36,6 +36,7 @@ namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
/// </remarks>
public class AkkaHostedServiceAuditWiringHoconTests
{
/// <summary>Verifies that the generated HOCON contains the audit telemetry dispatcher block.</summary>
[Fact]
public void BuildHocon_Emits_AuditTelemetryDispatcher_Block()
{
@@ -84,7 +85,7 @@ public class AkkaHostedServiceAuditWiringHoconTests
}
/// <summary>
/// Verifies Audit Log (#23) services land in the Central composition root.
/// Verifies Audit Log services land in the Central composition root.
/// </summary>
[Collection(HostBootCollection.Name)]
public class CentralAuditWiringTests : IDisposable
@@ -93,6 +94,7 @@ public class CentralAuditWiringTests : IDisposable
private readonly string? _previousEnv;
private readonly CentralDbTestEnvironment _dbEnv;
/// <summary>Initializes the Central composition root host used by the tests.</summary>
public CentralAuditWiringTests()
{
_previousEnv = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT");
@@ -119,7 +121,7 @@ public class CentralAuditWiringTests : IDisposable
["ScadaBridge:Cluster:SeedNodes:1"] = "akka.tcp://scadabridge@localhost:2552",
["ScadaBridge:Database:SkipMigrations"] = "true",
["ScadaBridge:Security:JwtSigningKey"] = "test-signing-key-must-be-at-least-32-chars-long!",
// Task 1.4: LDAP settings nest under Security:Ldap (shared LdapOptions).
// LDAP settings nest under Security:Ldap (shared LdapOptions).
// ServiceAccountDn is now required by the library's LdapOptionsValidator
// (ValidateOnStart), so it must be present for the host to start.
["ScadaBridge:Security:Ldap:Server"] = "localhost",
@@ -155,6 +157,7 @@ public class CentralAuditWiringTests : IDisposable
_ = _factory.Server;
}
/// <summary>Releases the resources held by the test fixture.</summary>
public void Dispose()
{
// try/finally: a throwing host teardown must not strand the process-wide
@@ -170,6 +173,7 @@ public class CentralAuditWiringTests : IDisposable
}
}
/// <summary>Verifies that the Central composition root resolves the audit writer as the fallback audit writer.</summary>
[Fact]
public void Central_Resolves_IAuditWriter_AsFallbackAuditWriter()
{
@@ -183,6 +187,7 @@ public class CentralAuditWiringTests : IDisposable
Assert.IsType<FallbackAuditWriter>(writer);
}
/// <summary>Verifies that the Central composition root resolves the audit log options.</summary>
[Fact]
public void Central_Resolves_AuditLogOptions()
{
@@ -191,6 +196,7 @@ public class CentralAuditWiringTests : IDisposable
Assert.NotNull(opts!.Value);
}
/// <summary>Verifies that the Central composition root resolves the SQLite audit writer options.</summary>
[Fact]
public void Central_Resolves_SqliteAuditWriterOptions()
{
@@ -199,6 +205,7 @@ public class CentralAuditWiringTests : IDisposable
Assert.NotNull(opts!.Value);
}
/// <summary>Verifies that the Central composition root resolves the site audit telemetry options.</summary>
[Fact]
public void Central_Resolves_SiteAuditTelemetryOptions()
{
@@ -207,6 +214,7 @@ public class CentralAuditWiringTests : IDisposable
Assert.NotNull(opts!.Value);
}
/// <summary>Verifies that the Central composition root resolves the site stream audit client as the no-op default.</summary>
[Fact]
public void Central_Resolves_ISiteStreamAuditClient_AsNoOpDefault()
{
@@ -216,7 +224,7 @@ public class CentralAuditWiringTests : IDisposable
}
/// <summary>
/// M3 Bundle F (T15): the Central composition root calls
/// The Central composition root calls
/// <c>AddSiteCallAudit()</c>. Today that extension is a no-op placeholder,
/// but invoking it must not throw and the central host's service collection
/// must build successfully — the actor's Props are constructed inline in
@@ -236,10 +244,10 @@ public class CentralAuditWiringTests : IDisposable
}
/// <summary>
/// M3 Bundle F: the Central composition root registers
/// The Central composition root registers
/// <c>ICachedCallTelemetryForwarder</c> as a lazy singleton (the
/// forwarder degrades to audit-only emission when the site-only
/// <c>IOperationTrackingStore</c> is absent, matching the M2 lazy chain
/// <c>IOperationTrackingStore</c> is absent, matching the lazy chain
/// pattern). The binding is exercised here so a future regression that
/// removes the registration or makes IOperationTrackingStore mandatory
/// fails on the Central node, not just at first script execution.
@@ -284,7 +292,7 @@ public class CentralAuditWiringTests : IDisposable
}
/// <summary>
/// Verifies Audit Log (#23) services land in the Site composition root.
/// Verifies Audit Log services land in the Site composition root.
/// </summary>
public class SiteAuditWiringTests : IDisposable
{
@@ -292,6 +300,7 @@ public class SiteAuditWiringTests : IDisposable
private readonly string _tempDbPath;
private readonly string _tempLocalDbPath;
/// <summary>Initializes the Site composition root host used by the tests.</summary>
public SiteAuditWiringTests()
{
_tempDbPath = Path.Combine(Path.GetTempPath(), $"scadabridge_audit_wiring_{Guid.NewGuid()}.db");
@@ -329,6 +338,7 @@ public class SiteAuditWiringTests : IDisposable
_host = builder.Build();
}
/// <summary>Releases the resources held by the test fixture.</summary>
public void Dispose()
{
(_host as IDisposable)?.Dispose();
@@ -336,6 +346,7 @@ public class SiteAuditWiringTests : IDisposable
try { File.Delete(_tempLocalDbPath); } catch { /* best effort */ }
}
/// <summary>Verifies that the Site composition root resolves the audit writer as the fallback audit writer.</summary>
[Fact]
public void Site_Resolves_IAuditWriter_AsFallbackAuditWriter()
{
@@ -344,6 +355,7 @@ public class SiteAuditWiringTests : IDisposable
Assert.IsType<FallbackAuditWriter>(writer);
}
/// <summary>Verifies that the Site composition root resolves the SQLite audit writer as a singleton.</summary>
[Fact]
public void Site_Resolves_SqliteAuditWriter_AsSingleton()
{
@@ -354,6 +366,7 @@ public class SiteAuditWiringTests : IDisposable
Assert.Same(a, b);
}
/// <summary>Verifies that the site audit queue and the SQLite audit writer resolve to the same instance.</summary>
[Fact]
public void Site_ISiteAuditQueue_AndSqliteAuditWriter_AreSameInstance()
{
@@ -367,6 +380,7 @@ public class SiteAuditWiringTests : IDisposable
Assert.Same(writer, queue);
}
/// <summary>Verifies that the Site composition root resolves the ring buffer fallback.</summary>
[Fact]
public void Site_Resolves_RingBufferFallback()
{
@@ -374,10 +388,11 @@ public class SiteAuditWiringTests : IDisposable
Assert.NotNull(ring);
}
/// <summary>Verifies that the Site composition root resolves the audit write failure counter as the health metrics bridge.</summary>
[Fact]
public void Site_Resolves_IAuditWriteFailureCounter_AsHealthMetricsBridge()
{
// Bundle G (M2-T11): site composition root calls
// The site composition root calls
// AddAuditLogHealthMetricsBridge() after AddAuditLog + AddSiteHealthMonitoring,
// which swaps the NoOp default for the real health-metrics bridge so
// FallbackAuditWriter primary failures surface in the site health
@@ -387,6 +402,7 @@ public class SiteAuditWiringTests : IDisposable
Assert.IsType<HealthMetricsAuditWriteFailureCounter>(counter);
}
/// <summary>Verifies that the Site composition root resolves the site stream audit client as the no-op default.</summary>
[Fact]
public void Site_Resolves_ISiteStreamAuditClient_AsNoOpDefault()
{
@@ -395,6 +411,7 @@ public class SiteAuditWiringTests : IDisposable
Assert.IsType<NoOpSiteStreamAuditClient>(client);
}
/// <summary>Verifies that the Site composition root resolves the site audit telemetry options with their default values.</summary>
[Fact]
public void Site_Resolves_SiteAuditTelemetryOptions_WithDefaults()
{
@@ -406,12 +423,12 @@ public class SiteAuditWiringTests : IDisposable
}
/// <summary>
/// M3 Bundle F (T15): the site composition root resolves the cached-call
/// The site composition root resolves the cached-call
/// telemetry forwarder. ScriptExecutionActor consumes this through
/// <c>GetService&lt;ICachedCallTelemetryForwarder&gt;()</c> on every script
/// execution; a missing registration would silently degrade
/// <c>ExternalSystem.CachedCall</c> / <c>Database.CachedWrite</c> to the
/// "no-emission" path and break the M3 audit pipeline.
/// "no-emission" path and break the cached-call audit pipeline.
/// </summary>
[Fact]
public void Site_Resolves_ICachedCallTelemetryForwarder()
@@ -422,7 +439,7 @@ public class SiteAuditWiringTests : IDisposable
}
/// <summary>
/// M3 Bundle F (T15): the site composition root resolves the lifecycle
/// The site composition root resolves the lifecycle
/// bridge that translates S&amp;F retry-loop attempt notifications into
/// cached-call telemetry packets.
/// </summary>
@@ -437,7 +454,7 @@ public class SiteAuditWiringTests : IDisposable
}
/// <summary>
/// M3 Bundle F (T15): the lifecycle bridge is bound to the
/// The lifecycle bridge is bound to the
/// <see cref="ICachedCallLifecycleObserver"/> contract that
/// StoreAndForwardService consults at construction time. Without this
/// binding the S&amp;F service is built with a null observer and the
@@ -454,7 +471,7 @@ public class SiteAuditWiringTests : IDisposable
}
/// <summary>
/// M3 Bundle F (T15): the Host registers an
/// The Host registers an
/// <see cref="IStoreAndForwardSiteContext"/> adapter so the S&amp;F service
/// can resolve the site id at composition time WITHOUT introducing a
/// StoreAndForward → HealthMonitoring project-reference cycle.
@@ -9,6 +9,8 @@ namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
public class BootstrapCancellationTests
{
/// <summary>Verifies that a cancelled bootstrap neither resolves the actor system nor joins the cluster.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task CancelledBootstrap_DoesNotResolveActorSystemOrJoin()
{
@@ -78,7 +78,7 @@ internal static class CaptiveDependencyGuard
/// It previously held two entries — <c>AuditLogIngestActor._injectedRepository</c> and
/// <c>SiteCallAuditActor._injectedRepository</c> — nullable repository fields assigned only
/// by explicitly test-mode constructors. They were safe solely because no production call
/// site picked those overloads, which left the row-45 captive-dependency bug one
/// site picked those overloads, which left the captive-dependency bug one
/// constructor call away. Register row 61 deleted the constructors instead: both actors now
/// have a single <see cref="IServiceProvider"/> constructor, the fields are gone, and the
/// test fixtures build stub providers that hand a repository out of a scope. With the shape
@@ -19,7 +19,7 @@ public class CaptiveDependencyGuardSelfTests
private interface IPretendScopedService;
/// <summary>
/// POSITIVE CONTROL — the pre-row-45 shape, preserved permanently. An actor that caches a
/// POSITIVE CONTROL — the original captive-dependency shape, preserved permanently. An actor that caches a
/// scoped-typed service for its lifetime is exactly what <c>NotificationOutboxActor</c>
/// looked like before 9e769735, and the guard must report it.
/// </summary>
@@ -67,6 +67,7 @@ public class CaptiveDependencyGuardSelfTests
private static readonly IReadOnlySet<Type> PretendScopedSet =
new HashSet<Type> { typeof(IPretendScopedService) };
/// <summary>Verifies that the guard flags an actor that caches a scoped service.</summary>
[Fact]
public void Guard_Flags_ActorCachingAScopedService()
{
@@ -78,6 +79,7 @@ public class CaptiveDependencyGuardSelfTests
Assert.Equal(typeof(IPretendScopedService), violation.FieldType);
}
/// <summary>Verifies that the guard flags an actor that caches a service scope even when the scoped set is empty.</summary>
[Fact]
public void Guard_Flags_ActorCachingAServiceScope_EvenWhenTheScopedSetIsEmpty()
{
@@ -90,6 +92,7 @@ public class CaptiveDependencyGuardSelfTests
Assert.Equal("_scope", violation.FieldName);
}
/// <summary>Verifies that the guard flags an actor that caches a DbContext.</summary>
[Fact]
public void Guard_Flags_ActorCachingADbContext()
{
@@ -100,6 +103,7 @@ public class CaptiveDependencyGuardSelfTests
Assert.Equal(typeof(ScadaBridgeDbContext), violation.FieldType);
}
/// <summary>Verifies that the guard flags the compiler-generated backing field of an auto-property of a scoped type.</summary>
[Fact]
public void Guard_Flags_ScopedAutoPropertyBackingField()
{
@@ -110,6 +114,7 @@ public class CaptiveDependencyGuardSelfTests
Assert.Contains("Captive", violation.FieldName, StringComparison.Ordinal);
}
/// <summary>Verifies that the guard allows fields that hold the root provider or a scope factory.</summary>
[Fact]
public void Guard_Allows_RootProviderAndScopeFactoryFields()
{
@@ -252,6 +257,7 @@ public class CentralCaptiveDependencyGuardTests : IDisposable
private readonly CentralDbTestEnvironment _dbEnv;
private readonly List<ServiceDescriptor> _descriptors = [];
/// <summary>Initializes a new instance of the <c>CentralCaptiveDependencyGuardTests</c> class.</summary>
public CentralCaptiveDependencyGuardTests()
{
_previousEnv = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT");
@@ -309,6 +315,7 @@ public class CentralCaptiveDependencyGuardTests : IDisposable
_ = _factory.Server;
}
/// <summary>Disposes the web application factory.</summary>
public void Dispose()
{
try
@@ -322,6 +329,7 @@ public class CentralCaptiveDependencyGuardTests : IDisposable
}
}
/// <summary>Verifies that no actor in the central composition root holds captive scoped state.</summary>
[Fact]
public void Central_NoActorHoldsCaptiveScopedState()
{
@@ -352,6 +360,7 @@ public class SiteCaptiveDependencyGuardTests : IDisposable
private readonly string _tempAuditDbPath;
private readonly IServiceCollection _services;
/// <summary>Initializes a new instance of the <c>SiteCaptiveDependencyGuardTests</c> class.</summary>
public SiteCaptiveDependencyGuardTests()
{
_tempDbPath = Path.Combine(Path.GetTempPath(), $"sb_guard_{Guid.NewGuid()}.db");
@@ -385,6 +394,7 @@ public class SiteCaptiveDependencyGuardTests : IDisposable
_services = builder.Services;
}
/// <summary>Deletes the temporary database files, ignoring failures.</summary>
public void Dispose()
{
foreach (var path in new[] { _tempDbPath, _tempTrackingDbPath, _tempLocalDbPath, _tempAuditDbPath })
@@ -393,6 +403,7 @@ public class SiteCaptiveDependencyGuardTests : IDisposable
}
}
/// <summary>Verifies that no actor in the site composition root holds captive scoped state.</summary>
[Fact]
public void Site_NoActorHoldsCaptiveScopedState()
{
@@ -30,11 +30,13 @@ public class CentralControlAuthInterceptorTests
/// <summary>An <see cref="ISitePskProvider"/> backed by a fixed site→key map; throws for unknown sites.</summary>
private sealed class MapPskProvider(IReadOnlyDictionary<string, string> keys) : ISitePskProvider
{
/// <inheritdoc />
public ValueTask<string> GetAsync(string siteId, CancellationToken ct)
=> keys.TryGetValue(siteId, out var key)
? new ValueTask<string>(key)
: throw new InvalidOperationException($"no key for '{siteId}'");
/// <inheritdoc />
public void Invalidate(string siteId) { }
}
@@ -58,22 +60,34 @@ public class CentralControlAuthInterceptorTests
private sealed class FakeServerCallContext(string method, Metadata requestHeaders)
: ServerCallContext
{
/// <inheritdoc />
protected override string MethodCore => method;
/// <inheritdoc />
protected override string HostCore => "localhost";
/// <inheritdoc />
protected override string PeerCore => "ipv4:127.0.0.1:12345";
/// <inheritdoc />
protected override DateTime DeadlineCore => DateTime.UtcNow.AddMinutes(1);
/// <inheritdoc />
protected override Metadata RequestHeadersCore => requestHeaders;
/// <inheritdoc />
protected override CancellationToken CancellationTokenCore => CancellationToken.None;
/// <inheritdoc />
protected override Metadata ResponseTrailersCore { get; } = [];
/// <inheritdoc />
protected override Status StatusCore { get; set; }
/// <inheritdoc />
protected override WriteOptions? WriteOptionsCore { get; set; }
/// <inheritdoc />
protected override AuthContext AuthContextCore { get; } =
new(null, new Dictionary<string, List<AuthProperty>>());
/// <inheritdoc />
protected override ContextPropagationToken CreatePropagationTokenCore(
ContextPropagationOptions? options)
=> throw new NotSupportedException();
/// <inheritdoc />
protected override Task WriteResponseHeadersAsyncCore(Metadata responseHeaders)
=> Task.CompletedTask;
}
@@ -83,6 +97,9 @@ public class CentralControlAuthInterceptorTests
=> interceptor.UnaryServerHandler<string, string>(
"request", context, (_, _) => Task.FromResult("ok"));
/// <summary>Verifies that a verified site A cannot submit ownership of site B for the given kind of request.</summary>
/// <param name="kind">The kind.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory]
[InlineData("notification")]
[InlineData("reconcile")]
@@ -128,6 +145,8 @@ public class CentralControlAuthInterceptorTests
Assert.False(continued); // no actor dispatch, token mint, or partial batch ingest
}
/// <summary>Verifies that a method outside the gated prefixes passes through.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task NonGatedMethod_PassesThrough()
{
@@ -139,6 +158,8 @@ public class CentralControlAuthInterceptorTests
Assert.Equal("ok", await Invoke(interceptor, context));
}
/// <summary>Verifies that a request with the correct site and key is accepted.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task CorrectSiteAndKey_IsAccepted()
{
@@ -148,6 +169,8 @@ public class CentralControlAuthInterceptorTests
Assert.Equal("ok", await Invoke(interceptor, context));
}
/// <summary>Verifies that a request with a missing site header is denied.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task MissingSiteHeader_IsDenied()
{
@@ -160,6 +183,8 @@ public class CentralControlAuthInterceptorTests
Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode);
}
/// <summary>Verifies that a request with a blank site header is denied.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task BlankSiteHeader_IsDenied()
{
@@ -170,6 +195,8 @@ public class CentralControlAuthInterceptorTests
Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode);
}
/// <summary>Verifies that a request from an unknown site is denied rather than passed through.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task UnknownSite_IsDenied_NotPassedThrough()
{
@@ -182,6 +209,8 @@ public class CentralControlAuthInterceptorTests
Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode);
}
/// <summary>Verifies that a request with no bearer token is denied.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task NoBearerToken_IsDenied()
{
@@ -192,6 +221,8 @@ public class CentralControlAuthInterceptorTests
Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode);
}
/// <summary>Verifies that a request with the wrong key for the site is denied.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task WrongKeyForTheSite_IsDenied()
{
@@ -204,6 +235,8 @@ public class CentralControlAuthInterceptorTests
Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode);
}
/// <summary>Verifies that each site is verified against its own key.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task EachSiteIsVerifiedAgainstItsOwnKey()
{
@@ -215,6 +248,7 @@ public class CentralControlAuthInterceptorTests
Assert.Equal("ok", await Invoke(interceptor, context));
}
/// <summary>Verifies that the interceptor has exactly one public constructor.</summary>
[Fact]
public void TheInterceptorHasExactlyOnePublicConstructor()
{
@@ -229,6 +263,7 @@ public class CentralControlAuthInterceptorTests
Assert.Single(publicCtors);
}
/// <summary>Verifies that the default gated prefixes match the real central control service path.</summary>
[Fact]
public void DefaultGatedPrefixes_MatchTheRealCentralControlServicePath()
{
@@ -53,7 +53,8 @@ public class CentralControlEndToEndTests : IAsyncLifetime
private ActorSystem _actorSystem = null!;
private CentralControlGrpcService _service = null!;
/// <inheritdoc />
/// <summary>Starts the in-memory host, actor system and gRPC service under test.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task InitializeAsync()
{
_actorSystem = ActorSystem.Create("centralcontrol-e2e-test");
@@ -94,7 +95,8 @@ public class CentralControlEndToEndTests : IAsyncLifetime
_server = _host.GetTestServer();
}
/// <inheritdoc />
/// <summary>Stops the host and actor system and releases test resources.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task DisposeAsync()
{
await _host.StopAsync();
@@ -118,6 +120,9 @@ public class CentralControlEndToEndTests : IAsyncLifetime
// ---- Auth positives / negatives, all through the real pipeline ----
/// <summary>Verifies that site A's key presented for a foreign site is denied before any actor dispatch.</summary>
/// <param name="rpc">The rpc.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory]
[InlineData("reconcile")]
[InlineData("notification")]
@@ -147,6 +152,8 @@ public class CentralControlEndToEndTests : IAsyncLifetime
Assert.Equal(0, Volatile.Read(ref _dispatches));
}
/// <summary>Verifies that the correct site and key reach the service on a unary call.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task CorrectSiteAndKey_ReachesTheService_OnAUnaryCall()
{
@@ -157,6 +164,8 @@ public class CentralControlEndToEndTests : IAsyncLifetime
Assert.True(ack.Accepted);
}
/// <summary>Verifies no credentials at all is rejected with permission denied.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task NoCredentialsAtAll_IsRejected_WithPermissionDenied()
{
@@ -168,6 +177,8 @@ public class CentralControlEndToEndTests : IAsyncLifetime
Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode);
}
/// <summary>Verifies that a wrong key for the site is rejected with a permission-denied status.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task WrongKeyForTheSite_IsRejected_WithPermissionDenied()
{
@@ -180,6 +191,8 @@ public class CentralControlEndToEndTests : IAsyncLifetime
Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode);
}
/// <summary>Verifies that an unknown site is rejected with a permission-denied status.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task UnknownSite_IsRejected_WithPermissionDenied()
{
@@ -191,6 +204,8 @@ public class CentralControlEndToEndTests : IAsyncLifetime
Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode);
}
/// <summary>Verifies the accept and reject paths are distinguishable.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task TheAcceptAndRejectPathsAreDistinguishable()
{
@@ -207,6 +222,8 @@ public class CentralControlEndToEndTests : IAsyncLifetime
// ---- One RPC per shape: unary (above) + the ingest bridge ----
/// <summary>Verifies that ingesting audit events decodes the batch, asks the ingest proxy and encodes the ack.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task IngestAuditEvents_DecodesTheBatch_AsksTheIngestProxy_EncodesTheAck()
{
@@ -224,6 +241,8 @@ public class CentralControlEndToEndTests : IAsyncLifetime
Assert.Contains(AcceptedId.ToString(), ack.AcceptedEventIds);
}
/// <summary>Verifies that ingesting an empty audit-event batch short-circuits without asking the ingest proxy.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task IngestAuditEvents_EmptyBatch_ShortCircuits_WithoutAskingTheIngestProxy()
{
@@ -259,17 +278,21 @@ public class CentralControlEndToEndTests : IAsyncLifetime
private sealed class FixedPskProvider(string key) : ISitePskProvider
{
/// <inheritdoc />
public ValueTask<string> GetAsync(string siteId, CancellationToken ct) => new(key);
/// <inheritdoc />
public void Invalidate(string siteId) { }
}
private sealed class MapPskProvider(IReadOnlyDictionary<string, string> keys) : ISitePskProvider
{
/// <inheritdoc />
public ValueTask<string> GetAsync(string siteId, CancellationToken ct)
=> keys.TryGetValue(siteId, out var key)
? new ValueTask<string>(key)
: throw new InvalidOperationException($"no key for '{siteId}'");
/// <inheritdoc />
public void Invalidate(string siteId) { }
}
@@ -280,6 +303,9 @@ public class CentralControlEndToEndTests : IAsyncLifetime
/// </summary>
private sealed class StubCentralActor : ReceiveActor
{
/// <summary>Initializes a new instance of the <c>StubCentralActor</c> class.</summary>
/// <param name="acceptedId">The accepted id.</param>
/// <param name="dispatched">The dispatched.</param>
public StubCentralActor(Guid acceptedId, Action dispatched)
{
Receive<NotificationSubmit>(msg => { dispatched();
@@ -1,7 +1,7 @@
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
/// <summary>
/// Host-003: <c>appsettings.Central.json</c> no longer commits database connection
/// <c>appsettings.Central.json</c> no longer commits database connection
/// strings — they are externalised to environment variables. Tests that exercise the
/// full <c>Program</c> startup pipeline against the real SQL provider must therefore
/// supply the local dev connection string the way a deployment would: via an
@@ -13,7 +13,7 @@ namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
/// <c>DOTNET_ENVIRONMENT=Central</c> without an explicit pepper env var.
///
/// Also supplies <c>ScadaBridge__Database__MachineDataDb</c> so the Central-role
/// StartupValidator preflight (reverts Host-008, REQ-HOST-3/4, M2.9 #17) does not
/// StartupValidator preflight (a missing MachineDataDb setting) does not
/// fail for tests that set <c>DOTNET_ENVIRONMENT=Central</c> without an explicit
/// MachineDataDb env var.
///
@@ -78,6 +78,7 @@ internal sealed class CentralDbTestEnvironment : IDisposable
/// </summary>
private static int _liveCount;
/// <summary>Initializes a new instance of <c>CentralDbTestEnvironment</c>.</summary>
public CentralDbTestEnvironment()
{
if (Interlocked.Increment(ref _liveCount) != 1)
@@ -107,6 +108,7 @@ internal sealed class CentralDbTestEnvironment : IDisposable
Environment.SetEnvironmentVariable(JwtSigningKeyKey, JwtSigningKey);
}
/// <summary>Releases the resources held by the test fixture.</summary>
public void Dispose()
{
Environment.SetEnvironmentVariable(ConfigKey, _previousConfig);
@@ -15,6 +15,9 @@ namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
/// </remarks>
public class CentralHttpBindPortsTests
{
/// <summary>Verifies that the HTTP bind ports are parsed correctly from the server URLs.</summary>
/// <param name="urls">The server URL list to parse.</param>
/// <param name="expected">The ports expected from the parse.</param>
[Theory]
[InlineData("http://+:5000", new[] { 5000 })]
[InlineData("http://0.0.0.0:5000", new[] { 5000 })]
@@ -29,6 +32,9 @@ public class CentralHttpBindPortsTests
Assert.Equal(expected, Program.ParseHttpBindPorts(urls));
}
/// <summary>Verifies that the scheme's default port is used when none is given and an explicit port is honoured.</summary>
/// <param name="url">The server URL to parse.</param>
/// <param name="expected">The port expected from the parse.</param>
[Theory]
[InlineData("https://+:443", 443)] // scheme default when no explicit port
[InlineData("http://+:80", 80)]
@@ -37,6 +43,8 @@ public class CentralHttpBindPortsTests
Assert.Equal(new[] { expected }, Program.ParseHttpBindPorts(url));
}
/// <summary>Verifies that a null, empty or whitespace URL list yields no ports.</summary>
/// <param name="urls">The null, empty or whitespace URL list.</param>
[Theory]
[InlineData(null)]
[InlineData("")]
@@ -46,6 +54,7 @@ public class CentralHttpBindPortsTests
Assert.Empty(Program.ParseHttpBindPorts(urls));
}
/// <summary>Verifies that an unparseable URL is skipped rather than thrown on, while valid entries still yield ports.</summary>
[Fact]
public void UnparseableEntry_IsSkipped_NotThrown()
{
@@ -14,6 +14,8 @@ namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
public class CentralListenerIsolationTests
{
/// <summary>Verifies that the actual socket decides the endpoint even when the request authority is forged.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task XC1_ActualSocketOwnsEndpoint_EvenWithForgedAuthority()
{
@@ -67,6 +69,7 @@ public class CentralListenerIsolationTests
public sealed class EchoCentral : CentralControlService.CentralControlServiceBase
{
/// <inheritdoc />
public override Task<Empty> Heartbeat(HeartbeatDto request, ServerCallContext context) => Task.FromResult(new Empty());
}
}
@@ -14,6 +14,8 @@ public class ClusterActivityEvaluatorTests : IAsyncLifetime
{
private ActorSystem? _system;
/// <summary>Prepares the fixture before the tests run.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task InitializeAsync()
{
var port = FreePort();
@@ -34,8 +36,11 @@ akka {{
await Task.Delay(100);
}
/// <summary>Releases the resources held by this fixture asynchronously.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task DisposeAsync() { if (_system != null) await _system.Terminate(); }
/// <summary>Verifies that self is oldest, sole up member, returns true.</summary>
[Fact]
public void SelfIsOldest_SoleUpMember_ReturnsTrue()
{
@@ -44,6 +49,7 @@ akka {{
Assert.True(ClusterActivityEvaluator.SelfIsOldest(cluster, "Central"));
}
/// <summary>Verifies that self is oldest, role not held, returns false.</summary>
[Fact]
public void SelfIsOldest_RoleNotHeld_ReturnsFalse()
{
@@ -52,12 +58,14 @@ akka {{
Assert.False(ClusterActivityEvaluator.SelfIsOldest(cluster, "site-nonexistent"));
}
/// <summary>Verifies that active node gate, and, node provider, agree with evaluator.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ActiveNodeGate_And_NodeProvider_AgreeWithEvaluator()
{
// Both product gates must be backed by the oldest-member evaluator.
// A single-node Up cluster: evaluator says true, so both must say true.
// (Pinning test — the leader==oldest divergence is proven in Task 8's
// (Pinning test — the leader==oldest divergence is proven in the
// two-node integration test; here we assert the wiring holds.)
var port = FreePort();
var self = $"akka.tcp://scadabridge@127.0.0.1:{port}";
@@ -45,6 +45,8 @@ namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
/// </summary>
internal static class AkkaHostedServiceRemover
{
/// <summary>Removes only the Akka hosted service registration, leaving the Akka services resolvable.</summary>
/// <param name="services">The service collection to modify.</param>
internal static void RemoveAkkaHostedServiceOnly(IServiceCollection services)
{
// Pattern used in Program.cs:
@@ -88,6 +90,7 @@ public class CentralCompositionRootTests : IDisposable
private readonly string? _previousEnv;
private readonly CentralDbTestEnvironment _dbEnv;
/// <summary>Initializes the tests by booting the central composition root with test configuration.</summary>
public CentralCompositionRootTests()
{
_previousEnv = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT");
@@ -114,7 +117,7 @@ public class CentralCompositionRootTests : IDisposable
["ScadaBridge:Cluster:SeedNodes:1"] = "akka.tcp://scadabridge@localhost:2552",
["ScadaBridge:Database:SkipMigrations"] = "true",
["ScadaBridge:Security:JwtSigningKey"] = "test-signing-key-must-be-at-least-32-chars-long!",
// Task 1.4: LDAP settings nest under Security:Ldap (shared LdapOptions).
// LDAP settings nest under Security:Ldap (shared LdapOptions).
// ServiceAccountDn is now required by the library's LdapOptionsValidator
// (ValidateOnStart), so it must be present for the host to start.
["ScadaBridge:Security:Ldap:Server"] = "localhost",
@@ -159,6 +162,7 @@ public class CentralCompositionRootTests : IDisposable
_ = _factory.Server;
}
/// <summary>Disposes the web application factory and restores the environment variables.</summary>
public void Dispose()
{
// try/finally: a throwing host teardown must not strand the process-wide
@@ -176,6 +180,8 @@ public class CentralCompositionRootTests : IDisposable
// --- Singletons ---
/// <summary>Verifies that the service resolves as a singleton from the central composition root.</summary>
/// <param name="serviceType">The service type to resolve.</param>
[Theory]
[MemberData(nameof(CentralSingletonServices))]
public void Central_ResolveSingleton(Type serviceType)
@@ -184,6 +190,7 @@ public class CentralCompositionRootTests : IDisposable
Assert.NotNull(service);
}
/// <summary>Gets the service types expected to resolve as singletons on the central node.</summary>
public static IEnumerable<object[]> CentralSingletonServices => new[]
{
new object[] { typeof(CommunicationService) },
@@ -194,12 +201,14 @@ public class CentralCompositionRootTests : IDisposable
new object[] { typeof(OAuth2TokenService) },
new object[] { typeof(InboundScriptExecutor) },
// Security: the shared ZB.MOM.WW.Auth.Ldap service is registered as a stateless
// singleton by AddZbLdapAuth (Task 1.2), replacing the old scoped LdapAuthService.
// singleton by AddZbLdapAuth, replacing the old scoped LdapAuthService.
new object[] { typeof(ILdapAuthService) },
};
// --- Scoped services ---
/// <summary>Verifies that the service resolves from a scope in the central composition root.</summary>
/// <param name="serviceType">The service type to resolve.</param>
[Theory]
[MemberData(nameof(CentralScopedServices))]
public void Central_ResolveScoped(Type serviceType)
@@ -209,6 +218,7 @@ public class CentralCompositionRootTests : IDisposable
Assert.NotNull(service);
}
/// <summary>Gets the service types expected to resolve as scoped services on the central node.</summary>
public static IEnumerable<object[]> CentralScopedServices => new[]
{
// TemplateEngine
@@ -235,7 +245,7 @@ public class CentralCompositionRootTests : IDisposable
new object[] { typeof(IDatabaseGateway) },
// NotificationService — central-only SMTP primitives. The orphan
// NotificationDeliveryService + INotificationDeliveryService were removed
// (NS-019) when sites stopped delivering notifications; the central
// when sites stopped delivering notifications; the central
// EmailNotificationDeliveryAdapter is now the only resolver of these
// primitives.
new object[] { typeof(Func<ISmtpClientWrapper>) },
@@ -258,6 +268,8 @@ public class CentralCompositionRootTests : IDisposable
// --- Transient services ---
/// <summary>Verifies that the service resolves as a transient from the central composition root.</summary>
/// <param name="serviceType">The service type to resolve.</param>
[Theory]
[MemberData(nameof(CentralTransientServices))]
public void Central_ResolveTransient(Type serviceType)
@@ -267,6 +279,7 @@ public class CentralCompositionRootTests : IDisposable
Assert.NotNull(service);
}
/// <summary>Gets the service types expected to resolve as transient services on the central node.</summary>
public static IEnumerable<object[]> CentralTransientServices => new[]
{
new object[] { typeof(FlatteningService) },
@@ -279,6 +292,8 @@ public class CentralCompositionRootTests : IDisposable
// --- Options ---
/// <summary>Verifies that the options type resolves from the central composition root.</summary>
/// <param name="optionsType">The options type to resolve.</param>
[Theory]
[MemberData(nameof(CentralOptions))]
public void Central_ResolveOptions(Type optionsType)
@@ -287,6 +302,7 @@ public class CentralCompositionRootTests : IDisposable
Assert.NotNull(service);
}
/// <summary>Gets the options types expected to be registered on the central node.</summary>
public static IEnumerable<object[]> CentralOptions => new[]
{
new object[] { typeof(IOptions<NodeOptions>) },
@@ -304,6 +320,7 @@ public class CentralCompositionRootTests : IDisposable
// --- Hosted services ---
/// <summary>Verifies that the central health aggregator is registered as a hosted service.</summary>
[Fact]
public void Central_CentralHealthAggregator_RegisteredAsHostedService()
{
@@ -311,10 +328,10 @@ public class CentralCompositionRootTests : IDisposable
Assert.Contains(hostedServices, s => s.GetType() == typeof(CentralHealthAggregator));
}
// --- InboundAPI-022 regression ---
// --- Active-node gate regression ---
/// <summary>
/// InboundAPI-022 regression: the Central composition root MUST register a
/// Regression test: the Central composition root MUST register a
/// concrete <see cref="IActiveNodeGate"/> implementation. Without it,
/// <see cref="InboundApiEndpointFilter"/> falls through to "allow" and a
/// standby central node continues to serve the inbound API, racing the
@@ -348,6 +365,7 @@ public class CentralCompositionRootTests : IDisposable
/// the gap is invisible until the graph is actually built — the same defect class as the
/// four caught in the secrets library itself.
/// </summary>
/// <param name="serviceType">The Secrets.Ui component dependency type to resolve.</param>
[Theory]
[InlineData(typeof(ZB.MOM.WW.Secrets.Abstractions.ISecretStore))]
[InlineData(typeof(ZB.MOM.WW.Secrets.Abstractions.ISecretCipher))]
@@ -384,6 +402,7 @@ public class CentralCompositionRootTests : IDisposable
Assert.Null(_factory.Services.GetService<IInboundApiKeyStoreImporter>());
}
/// <summary>Verifies that the shared audit seam resolves to the central direct-write bridge.</summary>
[Fact]
public void Central_SharedAuditSeam_IsTheCentralDirectWriteBridge()
{
@@ -508,11 +527,13 @@ public class CentralSqlServerKeyStoreCompositionRootTests : IDisposable
_ = _factory.Server;
}
/// <summary>Initializes the tests by booting the central composition root with the SQL Server key store provider.</summary>
public CentralSqlServerKeyStoreCompositionRootTests()
: this(explicitConnectionString: null)
{
}
/// <summary>Disposes the web application factory and restores the environment variables.</summary>
public void Dispose()
{
try
@@ -528,6 +549,9 @@ public class CentralSqlServerKeyStoreCompositionRootTests : IDisposable
}
}
/// <summary>Verifies that the SQL Server provider resolves the shared stores to their SQL Server implementations.</summary>
/// <param name="serviceType">The service type to resolve.</param>
/// <param name="expectedImplementation">The implementation type expected for the service.</param>
[Theory]
[InlineData(typeof(ZB.MOM.WW.Auth.Abstractions.ApiKeys.IApiKeyStore),
typeof(ZB.MOM.WW.Auth.ApiKeys.SqlServer.SqlServerApiKeyStore))]
@@ -583,6 +607,7 @@ public class CentralSqlServerKeyStoreCompositionRootTests : IDisposable
public sealed class CentralSqlServerKeyStoreExplicitConnectionStringTests
: CentralSqlServerKeyStoreCompositionRootTests
{
/// <summary>Initializes the tests with an explicit key store connection string.</summary>
public CentralSqlServerKeyStoreExplicitConnectionStringTests()
: base(ExplicitConnectionString)
{
@@ -613,6 +638,7 @@ public class SiteCompositionRootTests : IDisposable
private readonly string _tempTrackingDbPath;
private readonly string _tempLocalDbPath;
/// <summary>Initializes the tests with temporary database paths for the site composition root.</summary>
public SiteCompositionRootTests()
{
_tempDbPath = Path.Combine(Path.GetTempPath(), $"scadabridge_test_{Guid.NewGuid()}.db");
@@ -655,6 +681,7 @@ public class SiteCompositionRootTests : IDisposable
_host = builder.Build();
}
/// <summary>Disposes the host and deletes the temporary database files, ignoring failures.</summary>
public void Dispose()
{
(_host as IDisposable)?.Dispose();
@@ -663,6 +690,7 @@ public class SiteCompositionRootTests : IDisposable
try { File.Delete(_tempLocalDbPath); } catch { /* best effort */ }
}
/// <summary>Verifies that the operation tracking store resolves on a site node.</summary>
[Fact]
public void Site_Resolves_IOperationTrackingStore()
{
@@ -678,6 +706,8 @@ public class SiteCompositionRootTests : IDisposable
// --- Singletons ---
/// <summary>Verifies that the service resolves as a singleton from the site composition root.</summary>
/// <param name="serviceType">The service type to resolve.</param>
[Theory]
[MemberData(nameof(SiteSingletonServices))]
public void Site_ResolveSingleton(Type serviceType)
@@ -686,6 +716,7 @@ public class SiteCompositionRootTests : IDisposable
Assert.NotNull(service);
}
/// <summary>Gets the service types expected to resolve as singletons on a site node.</summary>
public static IEnumerable<object[]> SiteSingletonServices => new[]
{
new object[] { typeof(CommunicationService) },
@@ -703,15 +734,16 @@ public class SiteCompositionRootTests : IDisposable
new object[] { typeof(IEventLogQueryService) },
new object[] { typeof(ISiteIdentityProvider) },
new object[] { typeof(IHealthReportTransport) },
// M2.15 (#29): the active-node check must be registered on site nodes so
// EventLogPurge's log line can report whether this node is active (arch-review
// 2026-08-19 P4-4: it no longer gates which node purges — every node purges its
// own site_events file).
// The active-node check must be registered on site nodes so
// EventLogPurge's log line can report whether this node is active (it no
// longer gates which node purges — every node purges its own site_events file).
new object[] { typeof(SiteEventLogActiveNodeCheck) },
};
// --- Scoped services ---
/// <summary>Verifies that the service resolves from a scope in the site composition root.</summary>
/// <param name="serviceType">The service type to resolve.</param>
[Theory]
[MemberData(nameof(SiteScopedServices))]
public void Site_ResolveScoped(Type serviceType)
@@ -721,11 +753,12 @@ public class SiteCompositionRootTests : IDisposable
Assert.NotNull(service);
}
/// <summary>Gets the service types expected to resolve as scoped services on a site node.</summary>
public static IEnumerable<object[]> SiteScopedServices => new[]
{
new object[] { typeof(IExternalSystemRepository) },
// INotificationRepository is intentionally absent — notification config is
// central-only (arch-review 08 §1.3/#23); the site-side variant was removed.
// central-only; the site-side variant was removed.
new object[] { typeof(ExternalSystemClient) },
new object[] { typeof(IExternalSystemClient) },
new object[] { typeof(DatabaseGateway) },
@@ -734,6 +767,7 @@ public class SiteCompositionRootTests : IDisposable
// --- Implementation type assertions ---
/// <summary>Verifies that the external system repository resolves to the site implementation.</summary>
[Fact]
public void Site_ExternalSystemRepository_IsSiteImplementation()
{
@@ -742,6 +776,7 @@ public class SiteCompositionRootTests : IDisposable
Assert.IsType<SiteExternalSystemRepository>(repo);
}
/// <summary>Verifies that the notification repository is not registered on a site node.</summary>
[Fact]
public void Site_NotificationRepository_IsNotRegistered()
{
@@ -756,6 +791,8 @@ public class SiteCompositionRootTests : IDisposable
// --- Options ---
/// <summary>Verifies that the options type resolves from the site composition root.</summary>
/// <param name="optionsType">The options type to resolve.</param>
[Theory]
[MemberData(nameof(SiteOptions))]
public void Site_ResolveOptions(Type optionsType)
@@ -764,6 +801,7 @@ public class SiteCompositionRootTests : IDisposable
Assert.NotNull(service);
}
/// <summary>Gets the options types expected to be registered on a site node.</summary>
public static IEnumerable<object[]> SiteOptions => new[]
{
new object[] { typeof(IOptions<NodeOptions>) },
@@ -781,6 +819,8 @@ public class SiteCompositionRootTests : IDisposable
// --- Hosted services ---
/// <summary>Verifies that the hosted service is registered on a site node.</summary>
/// <param name="expectedType">The hosted service type expected to be registered.</param>
[Theory]
[MemberData(nameof(SiteHostedServices))]
public void Site_HostedServiceRegistered(Type expectedType)
@@ -789,6 +829,7 @@ public class SiteCompositionRootTests : IDisposable
Assert.Contains(hostedServices, s => s.GetType() == expectedType);
}
/// <summary>Gets the hosted service types expected to be registered on a site node.</summary>
public static IEnumerable<object[]> SiteHostedServices => new[]
{
new object[] { typeof(HealthReportSender) },
@@ -4,7 +4,7 @@ using System.Text.Json;
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
/// <summary>
/// Host-003 regression: secrets must not be committed in plaintext in the
/// Regression: secrets must not be committed in plaintext in the
/// shipped <c>appsettings.Central.json</c>. Connection-string passwords, the LDAP
/// service-account password and the JWT signing key must be supplied via
/// environment variables (or another secret store) at deployment time — the
@@ -35,6 +35,7 @@ public class ConfigSecretsTests
return doc.RootElement.GetProperty("ScadaBridge").Clone();
}
/// <summary>Verifies that the central config's connection strings contain no plaintext password.</summary>
[Fact]
public void CentralConfig_ConnectionStrings_ContainNoPlaintextPassword()
{
@@ -58,10 +59,11 @@ public class ConfigSecretsTests
}
}
/// <summary>Verifies that the central config does not commit the LDAP service-account password.</summary>
[Fact]
public void CentralConfig_LdapServiceAccountPassword_IsNotCommitted()
{
// Task 1.4 cutover: the LDAP service-account password moved out of the flat
// Cutover: the LDAP service-account password moved out of the flat
// Security:LdapServiceAccountPassword key into the nested Security:Ldap
// sub-section (Security:Ldap:ServiceAccountPassword), bound to the shared
// ZB.MOM.WW.Auth LdapOptions. Walk into Security:Ldap and guard the nested
@@ -78,6 +80,7 @@ public class ConfigSecretsTests
}
}
/// <summary>Verifies that the central config does not commit the JWT signing key.</summary>
[Fact]
public void CentralConfig_JwtSigningKey_IsNotCommitted()
{
@@ -40,6 +40,7 @@ public class ControlPlaneAuthEndToEndTests : IAsyncLifetime
private TestServer _server = null!;
/// <summary>Boots the in-process gRPC host with the real interceptor.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task InitializeAsync()
{
_host = await new HostBuilder()
@@ -69,7 +70,8 @@ public class ControlPlaneAuthEndToEndTests : IAsyncLifetime
_server = _host.GetTestServer();
}
/// <inheritdoc />
/// <summary>Stops and disposes the in-process gRPC host.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task DisposeAsync()
{
await _host.StopAsync();
@@ -93,7 +95,9 @@ public class ControlPlaneAuthEndToEndTests : IAsyncLifetime
private sealed class FixedPskProvider(string key) : ISitePskProvider
{
/// <inheritdoc />
public ValueTask<string> GetAsync(string siteId, CancellationToken ct) => new(key);
/// <inheritdoc />
public void Invalidate(string siteId) { }
}
@@ -103,6 +107,7 @@ public class ControlPlaneAuthEndToEndTests : IAsyncLifetime
/// <summary>The site header the last accepted call carried.</summary>
public string? LastSiteHeader { get; private set; }
/// <inheritdoc />
public override Task<PullAuditEventsResponse> PullAuditEvents(
PullAuditEventsRequest request, ServerCallContext context)
{
@@ -111,6 +116,7 @@ public class ControlPlaneAuthEndToEndTests : IAsyncLifetime
return Task.FromResult(new PullAuditEventsResponse { MoreAvailable = false });
}
/// <inheritdoc />
public override async Task SubscribeInstance(
InstanceStreamRequest request,
IServerStreamWriter<SiteStreamEvent> responseStream,
@@ -125,6 +131,8 @@ public class ControlPlaneAuthEndToEndTests : IAsyncLifetime
}
}
/// <summary>Verifies that the control-plane channel can receive a worst-case audit-sized message.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task CM1_ControlPlaneChannelReceivesWorstCaseAuditSizedMessage()
{
@@ -135,6 +143,8 @@ public class ControlPlaneAuthEndToEndTests : IAsyncLifetime
Assert.Equal(17 * 1024 * 1024, call.ResponseStream.Current.CorrelationId.Length);
}
/// <summary>Verifies that a call carrying the correct key is accepted on a unary call.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task CorrectKey_IsAccepted_OnAUnaryCall()
{
@@ -146,6 +156,8 @@ public class ControlPlaneAuthEndToEndTests : IAsyncLifetime
Assert.False(reply.MoreAvailable);
}
/// <summary>Verifies that a call carrying the wrong key is rejected with a permission-denied status.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task WrongKey_IsRejected_WithPermissionDenied()
{
@@ -158,6 +170,8 @@ public class ControlPlaneAuthEndToEndTests : IAsyncLifetime
Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode);
}
/// <summary>Verifies that a call with no credentials is rejected.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task NoCredentialsAtAll_IsRejected()
{
@@ -171,6 +185,8 @@ public class ControlPlaneAuthEndToEndTests : IAsyncLifetime
Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode);
}
/// <summary>Verifies that the credentials are applied to streaming calls as well as unary calls.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task CredentialsApplyToStreamingCalls_NotJustUnaryOnes()
{
@@ -186,6 +202,8 @@ public class ControlPlaneAuthEndToEndTests : IAsyncLifetime
Assert.Equal("c1", call.ResponseStream.Current.CorrelationId);
}
/// <summary>Verifies that the wrong key is also rejected on streaming calls.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task WrongKey_IsRejected_OnStreamingCallsToo()
{
@@ -200,6 +218,8 @@ public class ControlPlaneAuthEndToEndTests : IAsyncLifetime
Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode);
}
/// <summary>Verifies that a key that cannot be resolved fails the call instead of sending it without credentials.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AnUnresolvableKey_FailsTheCall_RatherThanDialingWithoutOne()
{
@@ -225,11 +245,15 @@ public class ControlPlaneAuthEndToEndTests : IAsyncLifetime
private sealed class ThrowingPskProvider : ISitePskProvider
{
/// <inheritdoc />
public ValueTask<string> GetAsync(string siteId, CancellationToken ct)
=> throw new InvalidOperationException($"no key for '{siteId}'");
/// <inheritdoc />
public void Invalidate(string siteId) { }
}
/// <summary>Verifies that the site header is sent with the call so central can choose a per-site key.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task TheSiteHeaderTravels_SoCentralCanPickAPerSiteKeyInPhase1A()
{
@@ -52,22 +52,34 @@ public class ControlPlaneAuthInterceptorTests
private sealed class FakeServerCallContext(string method, Metadata requestHeaders)
: ServerCallContext
{
/// <inheritdoc />
protected override string MethodCore => method;
/// <inheritdoc />
protected override string HostCore => "localhost";
/// <inheritdoc />
protected override string PeerCore => "ipv4:127.0.0.1:12345";
/// <inheritdoc />
protected override DateTime DeadlineCore => DateTime.UtcNow.AddMinutes(1);
/// <inheritdoc />
protected override Metadata RequestHeadersCore => requestHeaders;
/// <inheritdoc />
protected override CancellationToken CancellationTokenCore => CancellationToken.None;
/// <inheritdoc />
protected override Metadata ResponseTrailersCore { get; } = [];
/// <inheritdoc />
protected override Status StatusCore { get; set; }
/// <inheritdoc />
protected override WriteOptions? WriteOptionsCore { get; set; }
/// <inheritdoc />
protected override AuthContext AuthContextCore { get; } =
new(null, new Dictionary<string, List<AuthProperty>>());
/// <inheritdoc />
protected override ContextPropagationToken CreatePropagationTokenCore(
ContextPropagationOptions? options)
=> throw new NotSupportedException();
/// <inheritdoc />
protected override Task WriteResponseHeadersAsyncCore(Metadata responseHeaders)
=> Task.CompletedTask;
}
@@ -78,6 +90,8 @@ public class ControlPlaneAuthInterceptorTests
=> interceptor.UnaryServerHandler<string, string>(
"request", context, (_, _) => Task.FromResult("ok"));
/// <summary>Verifies that the LocalDb sync method passes through because it has its own gate and its own key.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task LocalDbSyncMethod_PassesThrough_BecauseItHasItsOwnGateAndItsOwnKey()
{
@@ -90,6 +104,8 @@ public class ControlPlaneAuthInterceptorTests
Assert.Equal("ok", await Invoke(interceptor, context));
}
/// <summary>Verifies that a gated method is denied when no key is configured, even with a bearer token.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GatedMethod_WithNoKeyConfigured_IsDenied_EvenWithABearerToken()
{
@@ -103,6 +119,8 @@ public class ControlPlaneAuthInterceptorTests
Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode);
}
/// <summary>Verifies that a gated method is denied when no bearer token is supplied.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GatedMethod_WithNoBearerToken_IsDenied()
{
@@ -113,6 +131,8 @@ public class ControlPlaneAuthInterceptorTests
Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode);
}
/// <summary>Verifies that a gated method is denied when the bearer token is wrong.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GatedMethod_WithWrongBearerToken_IsDenied()
{
@@ -123,6 +143,8 @@ public class ControlPlaneAuthInterceptorTests
Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode);
}
/// <summary>Verifies that a gated method passes through when the bearer token is correct.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GatedMethod_WithCorrectBearerToken_PassesThrough()
{
@@ -132,6 +154,8 @@ public class ControlPlaneAuthInterceptorTests
Assert.Equal("ok", await Invoke(interceptor, context));
}
/// <summary>Verifies that a gated method is denied when the correct key is sent without the Bearer scheme.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GatedMethod_WithCorrectKey_ButNoBearerScheme_IsDenied()
{
@@ -144,6 +168,8 @@ public class ControlPlaneAuthInterceptorTests
Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode);
}
/// <summary>Verifies that token comparison requires an exact match rather than a prefix match.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GatedMethod_TokenComparison_IsNotAPrefixMatch()
{
@@ -156,6 +182,8 @@ public class ControlPlaneAuthInterceptorTests
Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode);
}
/// <summary>Verifies that server-streaming calls are gated, since that is how subscriptions actually run.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ServerStreaming_IsGated_BecauseThatIsHowSubscriptionsActuallyRun()
{
@@ -174,6 +202,8 @@ public class ControlPlaneAuthInterceptorTests
Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode);
}
/// <summary>Verifies that the pull RPCs are gated because they return audit rows.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PullRpcs_AreGated_BecauseTheyReturnAuditRows()
{
@@ -187,6 +217,8 @@ public class ControlPlaneAuthInterceptorTests
Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode);
}
/// <summary>Verifies that the gated prefixes are supplied through the constructor, so later phases add services rather than interceptors.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GatedPrefixes_AreConstructorProvided_SoLaterPhasesAddServicesNotInterceptors()
{
@@ -205,6 +237,7 @@ public class ControlPlaneAuthInterceptorTests
Assert.Equal("ok", await Invoke(interceptor, notGated));
}
/// <summary>Verifies that the interceptor exposes exactly one public constructor.</summary>
[Fact]
public void TheInterceptorHasExactlyOnePublicConstructor()
{
@@ -221,6 +254,7 @@ public class ControlPlaneAuthInterceptorTests
Assert.Single(publicCtors);
}
/// <summary>Verifies that the default gated prefixes match the real site stream service path.</summary>
[Fact]
public void DefaultGatedPrefixes_MatchTheRealSiteStreamServicePath()
{
@@ -3,11 +3,12 @@ using System.Reflection;
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
/// <summary>
/// WP-16: Tests for CoordinatedShutdown configuration.
/// Tests for CoordinatedShutdown configuration.
/// Verifies no Environment.Exit calls exist in source and HOCON config is correct.
/// </summary>
public class CoordinatedShutdownTests
{
/// <summary>Verifies that host source does not contain environment exit.</summary>
[Fact]
public void HostSource_DoesNotContainEnvironmentExit()
{
@@ -48,6 +49,7 @@ public class CoordinatedShutdownTests
}
}
/// <summary>Verifies that akka hosted service hocon config includes coordinated shutdown settings.</summary>
[Fact]
public void AkkaHostedService_HoconConfig_IncludesCoordinatedShutdownSettings()
{
@@ -65,6 +67,7 @@ public class CoordinatedShutdownTests
Assert.Contains("run-coordinated-shutdown-when-down = on", content);
}
/// <summary>Verifies that all central singletons register through registrar with drain.</summary>
[Fact]
public void AllCentralSingletons_RegisterThroughRegistrarWithDrain()
{
@@ -91,6 +94,7 @@ public class CoordinatedShutdownTests
Assert.Equal(0, CountOccurrences(content, "ClusterSingletonManager.Props("));
}
/// <summary>Verifies that site singletons register through registrar with drain.</summary>
[Fact]
public void SiteSingletons_RegisterThroughRegistrarWithDrain()
{
@@ -98,7 +102,7 @@ public class CoordinatedShutdownTests
Assert.NotNull(hostProjectDir);
var content = File.ReadAllText(Path.Combine(hostProjectDir!, "Actors", "AkkaHostedService.cs"));
// Round-2 N5: the two role-scoped SITE singletons previously stayed
// The two role-scoped SITE singletons previously stayed
// hand-rolled (bare PoisonPill, no PhaseClusterLeave drain). They now
// go through SingletonRegistrar.Start(..., role: siteRole), which
// always adds the GracefulStop drain — in-flight SQLite writes
@@ -10,13 +10,14 @@ using ZB.MOM.WW.ScadaBridge.Host.Actors;
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
/// <summary>
/// WP-15: Tests for DeadLetterMonitorActor.
/// Tests for DeadLetterMonitorActor.
/// </summary>
public class DeadLetterMonitorTests : TestKit
{
private readonly ILogger<DeadLetterMonitorActor> _logger =
NullLoggerFactory.Instance.CreateLogger<DeadLetterMonitorActor>();
/// <summary>Verifies that a new dead-letter monitor starts with a count of zero.</summary>
[Fact]
public void DeadLetterMonitor_StartsWithZeroCount()
{
@@ -28,6 +29,7 @@ public class DeadLetterMonitorTests : TestKit
Assert.Equal(0, response.Count);
}
/// <summary>Verifies that the monitor count increments when a dead letter is published.</summary>
[Fact]
public void DeadLetterMonitor_IncrementsOnDeadLetter()
{
@@ -50,6 +52,8 @@ public class DeadLetterMonitorTests : TestKit
});
}
/// <summary>Verifies that a dead-letter storm logs at most the per-window warning limit while still counting every dead letter.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeadLetterStorm_LogsAtMostWindowLimit_ButCountsAll()
{
@@ -77,6 +81,7 @@ public class DeadLetterMonitorTests : TestKit
$"expected <= {DeadLetterMonitorActor.MaxWarningsPerWindow + 1} warnings, got {warnings}");
}
/// <summary>Verifies that the dead-letter count accumulates across multiple dead letters.</summary>
[Fact]
public void DeadLetterMonitor_CountAccumulates()
{
@@ -38,7 +38,8 @@ public class GrpcCentralTransportTests : IAsyncLifetime
private CentralNode _nodeA = null!;
private CentralNode _nodeB = null!;
/// <inheritdoc />
/// <summary>Starts the actor system and two in-process central nodes serving the same site key.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task InitializeAsync()
{
_system = ActorSystem.Create("grpc-central-transport-test");
@@ -46,7 +47,8 @@ public class GrpcCentralTransportTests : IAsyncLifetime
_nodeB = await CentralNode.StartAsync(_system, "B", SiteA, SiteAKey, repliesToSubmit: true);
}
/// <inheritdoc />
/// <summary>Disposes both central nodes and terminates the actor system.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task DisposeAsync()
{
await _nodeA.DisposeAsync();
@@ -81,6 +83,7 @@ public class GrpcCentralTransportTests : IAsyncLifetime
private GrpcCentralTransport NewTransport(CentralChannelProvider provider, CommunicationOptions? options = null)
=> new(provider, options ?? new CommunicationOptions(), NullLogger<GrpcCentralTransport>.Instance);
/// <summary>Verifies that a reconcile whose response is lost after it was applied fails as outcome-unknown instead of being re-sent to the peer.</summary>
[Fact]
public void ReconcileAppliedThenResponseLost_DoesNotMintAgainOnPeer()
{
@@ -96,6 +99,8 @@ public class GrpcCentralTransportTests : IAsyncLifetime
Assert.Equal(0, _nodeB.ReconcileCount);
}
/// <summary>Verifies that a submit reaches the preferred node and the ack is routed back to the caller.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task HappyPath_ReachesThePreferredNode_AndRoutesTheAckBack()
{
@@ -113,6 +118,8 @@ public class GrpcCentralTransportTests : IAsyncLifetime
Assert.Equal(0, _nodeB.SubmitCount);
}
/// <summary>Verifies that the transport stays on the preferred node while it remains healthy.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Sticky_StaysOnThePreferredNode_WhileHealthy()
{
@@ -131,6 +138,8 @@ public class GrpcCentralTransportTests : IAsyncLifetime
Assert.Equal(0, _nodeB.SubmitCount);
}
/// <summary>Verifies that the transport fails over to the peer when the preferred node is unavailable.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Failover_FlipsToThePeer_WhenThePreferredIsUnavailable()
{
@@ -148,6 +157,8 @@ public class GrpcCentralTransportTests : IAsyncLifetime
Assert.Equal(1, _nodeB.SubmitCount);
}
/// <summary>Verifies that the transport returns to the preferred node once it is reachable again.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Failback_ReturnsToThePreferred_OnceItIsReachableAgain()
{
@@ -173,6 +184,8 @@ public class GrpcCentralTransportTests : IAsyncLifetime
Assert.True(_nodeA.SubmitCount >= 1);
}
/// <summary>Verifies that the pre-shared key and site header are attached so the gated call reaches the service.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PskAndSiteHeader_AreAttached_SoTheGatedCallReachesTheService()
{
@@ -188,6 +201,8 @@ public class GrpcCentralTransportTests : IAsyncLifetime
Assert.True(ack.Accepted);
}
/// <summary>Verifies that a wrong pre-shared key is rejected and not retried on the peer.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task WrongPsk_IsRejected_AndNotRetriedOnThePeer()
{
@@ -204,6 +219,8 @@ public class GrpcCentralTransportTests : IAsyncLifetime
Assert.Equal(0, _nodeB.SubmitCount); // peer never tried
}
/// <summary>Verifies that a deadline-exceeded failure is not retried on the peer.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeadlineExceeded_IsNotRetriedOnThePeer()
{
@@ -231,6 +248,8 @@ public class GrpcCentralTransportTests : IAsyncLifetime
Assert.Equal(0, _nodeB.SubmitCount); // peer never tried
}
/// <summary>Verifies that a black-holed node does not hang the caller because a per-call deadline is applied.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task BlackHoledNode_DoesNotHang_APerCallDeadlineIsApplied()
{
@@ -298,13 +317,19 @@ public class GrpcCentralTransportTests : IAsyncLifetime
{
private readonly BlockingCollection<object> _messages = new();
/// <summary>Initializes a new instance of the <see cref="Capture"/> class and starts its capture actor.</summary>
/// <param name="system">The actor system that hosts the capture actor.</param>
public Capture(ActorSystem system)
{
Ref = system.ActorOf(Props.Create(() => new CaptureActor(_messages)));
}
/// <summary>Gets the actor reference that tests pass as the reply target.</summary>
public IActorRef Ref { get; }
/// <summary>Waits for the next captured message.</summary>
/// <param name="timeout">The maximum time to wait for a message.</param>
/// <returns>The next captured message.</returns>
public object Receive(TimeSpan timeout)
=> _messages.TryTake(out var message, timeout)
? message
@@ -312,6 +337,8 @@ public class GrpcCentralTransportTests : IAsyncLifetime
private sealed class CaptureActor : ReceiveActor
{
/// <summary>Initializes a new instance of the <see cref="CaptureActor"/> class that forwards every message to the collection.</summary>
/// <param name="messages">The collection that receives every message.</param>
public CaptureActor(BlockingCollection<object> messages) => ReceiveAny(messages.Add);
}
}
@@ -329,12 +356,16 @@ public class GrpcCentralTransportTests : IAsyncLifetime
{
private readonly Func<Grpc.Core.StatusCode?> _forced;
/// <summary>Initializes a new instance of the <see cref="GrpcStatusHandler"/> class.</summary>
/// <param name="inner">The handler to delegate to when no status is forced.</param>
/// <param name="forced">Returns the gRPC status to force, or null to pass through.</param>
public GrpcStatusHandler(HttpMessageHandler inner, Func<Grpc.Core.StatusCode?> forced)
{
InnerHandler = inner;
_forced = forced;
}
/// <inheritdoc />
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
@@ -362,6 +393,7 @@ public class GrpcCentralTransportTests : IAsyncLifetime
/// <summary>A gRPC channel handler that throws (a refused connection) while its node is "down".</summary>
private sealed class LostReconcileResponseHandler(HttpMessageHandler inner, Func<bool> lose) : DelegatingHandler(inner)
{
/// <inheritdoc />
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
{
var response = await base.SendAsync(request, ct);
@@ -379,12 +411,16 @@ public class GrpcCentralTransportTests : IAsyncLifetime
{
private readonly Func<bool> _isUp;
/// <summary>Initializes a new instance of the <see cref="ToggleHandler"/> class.</summary>
/// <param name="inner">The handler to delegate to while the endpoint is up.</param>
/// <param name="isUp">Returns whether the endpoint should currently appear up.</param>
public ToggleHandler(HttpMessageHandler inner, Func<bool> isUp)
{
InnerHandler = inner;
_isUp = isUp;
}
/// <inheritdoc />
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
if (!_isUp())
@@ -398,9 +434,11 @@ public class GrpcCentralTransportTests : IAsyncLifetime
private sealed class FixedPskProvider(string? key) : ISitePskProvider
{
/// <inheritdoc />
public ValueTask<string> GetAsync(string siteId, CancellationToken ct)
=> key is null ? throw new InvalidOperationException("no key") : new ValueTask<string>(key);
/// <inheritdoc />
public void Invalidate(string siteId) { }
}
@@ -411,11 +449,21 @@ public class GrpcCentralTransportTests : IAsyncLifetime
private IActorRef _stub = null!;
private readonly StubCounters _counters = new();
/// <summary>Gets the in-process test server hosting the central control service.</summary>
public TestServer Server { get; private set; } = null!;
public volatile bool IsUp = true;
/// <summary>Gets the number of notification submits the stub actor has received.</summary>
public int SubmitCount => _counters.Submits;
/// <summary>Gets the number of reconcile requests the stub actor has received.</summary>
public int ReconcileCount => Volatile.Read(ref _counters.Reconciles);
/// <summary>Starts an in-process central node with a stub actor behind the gRPC control service.</summary>
/// <param name="system">The actor system that hosts the stub actor.</param>
/// <param name="label">A label that makes the stub actor name unique.</param>
/// <param name="site">The site id the node accepts.</param>
/// <param name="key">The pre-shared key for that site.</param>
/// <param name="repliesToSubmit">Whether the stub acknowledges notification submits.</param>
/// <returns>The started central node.</returns>
public static async Task<CentralNode> StartAsync(
ActorSystem system, string label, string site, string key, bool repliesToSubmit)
{
@@ -453,6 +501,8 @@ public class GrpcCentralTransportTests : IAsyncLifetime
/// <summary>Switches the node's actor to a black hole that counts but never replies.</summary>
public void SetBlackHole() => _counters.BlackHole = true;
/// <summary>Releases the resources held by this test class asynchronously.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async ValueTask DisposeAsync()
{
await _host.StopAsync();
@@ -463,13 +513,18 @@ public class GrpcCentralTransportTests : IAsyncLifetime
{
private int _submits;
public int Reconciles;
/// <summary>Gets the number of notification submits received.</summary>
public int Submits => Volatile.Read(ref _submits);
/// <summary>Records one received notification submit.</summary>
public void IncrementSubmits() => Interlocked.Increment(ref _submits);
public volatile bool BlackHole;
}
private sealed class StubCentralActor : ReceiveActor
{
/// <summary>Initializes a new instance of the <see cref="StubCentralActor"/> class.</summary>
/// <param name="counters">The counters the actor increments as it receives messages.</param>
/// <param name="repliesToSubmit">Whether the actor replies to submit requests.</param>
public StubCentralActor(StubCounters counters, bool repliesToSubmit)
{
Receive<NotificationSubmit>(msg =>
@@ -494,11 +549,13 @@ public class GrpcCentralTransportTests : IAsyncLifetime
private sealed class MapPskProvider(IReadOnlyDictionary<string, string> keys) : ISitePskProvider
{
/// <inheritdoc />
public ValueTask<string> GetAsync(string siteId, CancellationToken ct)
=> keys.TryGetValue(siteId, out var key)
? new ValueTask<string>(key)
: throw new InvalidOperationException($"no key for '{siteId}'");
/// <inheritdoc />
public void Invalidate(string siteId) { }
}
}
@@ -33,7 +33,8 @@ public sealed class GrpcSiteTransportFailoverTests : IAsyncLifetime
private SitePairChannelProvider _provider = null!;
private volatile bool _preferredReachable;
/// <inheritdoc />
/// <summary>Starts the stub servers and builds the channel provider under test.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task InitializeAsync()
{
(_hostA, _stubA) = await StartServerAsync();
@@ -52,7 +53,8 @@ public sealed class GrpcSiteTransportFailoverTests : IAsyncLifetime
_provider.UpdateSite(SiteId, EndpointA, EndpointB);
}
/// <inheritdoc />
/// <summary>Stops the stub servers and disposes the channel provider after the tests.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task DisposeAsync()
{
_provider.Dispose();
@@ -99,6 +101,8 @@ public sealed class GrpcSiteTransportFailoverTests : IAsyncLifetime
},
CancellationToken.None);
/// <summary>Verifies the case: healthy Call, hits Preferred Node A, with Psk And Site Header And Deadline.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task HealthyCall_HitsPreferredNodeA_WithPskAndSiteHeaderAndDeadline()
{
@@ -113,6 +117,8 @@ public sealed class GrpcSiteTransportFailoverTests : IAsyncLifetime
Assert.True(_provider.IsOnPreferredNode(SiteId));
}
/// <summary>Verifies the case: not Dispatched, on Node A, fails Over To Node B, and Stays Sticky.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task NotDispatched_OnNodeA_FailsOverToNodeB_AndStaysSticky()
{
@@ -132,6 +138,8 @@ public sealed class GrpcSiteTransportFailoverTests : IAsyncLifetime
Assert.Equal(2, _stubB.LifecycleCalls);
}
/// <summary>Verifies the case: failback, returns To Preferred Node A, once Preferred Is Reachable Again.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Failback_ReturnsToPreferredNodeA_OncePreferredIsReachableAgain()
{
@@ -152,6 +160,8 @@ public sealed class GrpcSiteTransportFailoverTests : IAsyncLifetime
Assert.Equal(beforeA + 1, _stubA.LifecycleCalls); // next call back on A
}
/// <summary>Verifies the case: deadline Exceeded, on Node A, is Not Retried On Node B.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeadlineExceeded_OnNodeA_IsNotRetriedOnNodeB()
{
@@ -168,6 +178,8 @@ public sealed class GrpcSiteTransportFailoverTests : IAsyncLifetime
Assert.True(_provider.IsOnPreferredNode(SiteId), "a deadline must not flip stickiness");
}
/// <summary>Verifies the case: ambiguous Unavailable, does Not Replay Lifecycle On Peer.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AmbiguousUnavailable_DoesNotReplayLifecycleOnPeer()
{
@@ -181,6 +193,8 @@ public sealed class GrpcSiteTransportFailoverTests : IAsyncLifetime
Assert.Equal(0, _stubB.LifecycleCalls);
}
/// <summary>Verifies the case: unknown Site, throws, site Channel Unavailable.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task UnknownSite_Throws_SiteChannelUnavailable()
{
@@ -191,7 +205,9 @@ public sealed class GrpcSiteTransportFailoverTests : IAsyncLifetime
private sealed class FixedPskProvider(string key) : ISitePskProvider
{
/// <inheritdoc />
public ValueTask<string> GetAsync(string siteId, CancellationToken ct) => new(key);
/// <inheritdoc />
public void Invalidate(string siteId) { }
}
@@ -199,13 +215,18 @@ public sealed class GrpcSiteTransportFailoverTests : IAsyncLifetime
private sealed class StubSiteCommandService : SiteCommandService.SiteCommandServiceBase
{
private int _lifecycleCalls;
/// <summary>Gets the number of lifecycle calls received.</summary>
public int LifecycleCalls => Volatile.Read(ref _lifecycleCalls);
/// <summary>Gets the authorization header of the last call received.</summary>
public string? LastAuthHeader { get; private set; }
/// <summary>Gets the site header of the last call received.</summary>
public string? LastSiteHeader { get; private set; }
/// <summary>Gets a value indicating whether the last call carried a deadline.</summary>
public bool DeadlineWasSet { get; private set; }
public volatile bool RejectBeforeDispatch;
public StatusCode? StatusToThrow;
/// <inheritdoc />
public override Task<LifecycleReply> ExecuteLifecycle(LifecycleRequest request, ServerCallContext context)
{
Interlocked.Increment(ref _lifecycleCalls);
@@ -11,7 +11,7 @@ using ZB.MOM.WW.ScadaBridge.Host.Health;
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
/// <summary>
/// WP-12: Tests for the three-tier health endpoints after adopting the shared
/// Tests for the three-tier health endpoints after adopting the shared
/// ZB.MOM.WW.Health probes. Verifies that /health/ready, /health/active and the new
/// /healthz tier are mapped, and that the readiness/active tier split is now carried by
/// the canonical <see cref="ZbHealthTags"/> (Ready for database + akka-cluster, Active for
@@ -23,12 +23,14 @@ public class HealthCheckTests : IDisposable
{
private readonly List<IDisposable> _disposables = new();
/// <summary>Initializes the test class and registers the central database test environment.</summary>
public HealthCheckTests()
{
// Host-003: connection strings are externalised; supply them via env vars.
// Connection strings are externalised; supply them via env vars.
_disposables.Add(new CentralDbTestEnvironment());
}
/// <summary>Releases the resources held by the test fixture.</summary>
public void Dispose()
{
foreach (var d in _disposables)
@@ -64,6 +66,8 @@ public class HealthCheckTests : IDisposable
private static IEnumerable<HealthCheckRegistration> Registrations(WebApplicationFactory<Program> factory) =>
factory.Services.GetRequiredService<IOptions<HealthCheckServiceOptions>>().Value.Registrations;
/// <summary>Verifies that the /health/ready endpoint is mapped.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task HealthReady_Endpoint_IsMapped()
{
@@ -92,6 +96,8 @@ public class HealthCheckTests : IDisposable
}
}
/// <summary>Verifies that the /health/active endpoint is mapped.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task HealthActive_Endpoint_IsMapped()
{
@@ -117,6 +123,8 @@ public class HealthCheckTests : IDisposable
}
}
/// <summary>Verifies that the /healthz liveness endpoint is mapped and returns 200.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Healthz_LivenessEndpoint_IsMappedAndReturns200()
{
@@ -140,10 +148,11 @@ public class HealthCheckTests : IDisposable
}
}
/// <summary>Verifies that the Ready tier carries the database and akka-cluster checks.</summary>
[Fact]
public void ReadyTier_Carries_Database_And_AkkaCluster()
{
// Host-001 regression guard: readiness reflects cluster membership + DB connectivity
// Regression guard: readiness reflects cluster membership + DB connectivity
// only (REQ-HOST-4a), NOT cluster leadership. The split is now carried by the Ready tag
// rather than a check-name predicate: database + akka-cluster are Ready-tagged, and the
// leader-only active-node check is NOT — so a fully operational standby central node
@@ -162,7 +171,7 @@ public class HealthCheckTests : IDisposable
Assert.Contains(ZbHealthTags.Ready, registrations["database"].Tags);
Assert.Contains(ZbHealthTags.Ready, registrations["akka-cluster"].Tags);
// M2.14 (#28): readiness ALSO reflects "required cluster singletons running"
// Readiness ALSO reflects "required cluster singletons running"
// (REQ-HOST-4a). The Central-only required-singletons check is Ready-tagged so
// it gates /health/ready alongside database + akka-cluster, but is leadership-
// agnostic (it does NOT carry the Active tag), so a ready standby stays ready.
@@ -180,6 +189,7 @@ public class HealthCheckTests : IDisposable
}
}
/// <summary>Verifies that the Active tier carries the active-node and database checks.</summary>
[Fact]
public void ActiveTier_Carries_ActiveNode_And_Database()
{
@@ -207,6 +217,7 @@ public class HealthCheckTests : IDisposable
}
}
/// <summary>Verifies that the active-node check is the shared oldest-Up-member check.</summary>
[Fact]
public void ActiveNodeCheck_IsTheSharedOldestUpMemberCheck()
{
@@ -233,6 +244,7 @@ public class HealthCheckTests : IDisposable
}
}
/// <summary>Verifies that the database check also gates the Active tier.</summary>
[Fact]
public void DatabaseCheck_AlsoGatesActive()
{
@@ -5,7 +5,7 @@ using ZB.MOM.WW.ScadaBridge.Host.Actors;
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
/// <summary>
/// Host-006: the Akka HOCON document must be assembled with every interpolated value
/// The Akka HOCON document must be assembled with every interpolated value
/// quoted/escaped, so a hostname, seed-node URI or split-brain strategy containing a
/// quote, backslash or whitespace cannot corrupt the document or be silently
/// misparsed.
@@ -26,6 +26,7 @@ public class HoconBuilderTests
FailureDetectionThreshold = TimeSpan.FromSeconds(10),
};
/// <summary>Verifies that the cluster leave phase timeout exceeds the drain budget.</summary>
[Fact]
public void BuildHocon_ClusterLeavePhaseTimeout_ExceedsDrainBudget()
{
@@ -43,6 +44,7 @@ public class HoconBuilderTests
$"cluster-leave phase timeout ({phaseTimeout}) must exceed the 10s singleton GracefulStop budget");
}
/// <summary>Verifies that plain values produce a parseable document that preserves them.</summary>
[Fact]
public void BuildHocon_PlainValues_ParsesAndPreservesValues()
{
@@ -62,6 +64,7 @@ public class HoconBuilderTests
Assert.Equal("keep-oldest", config.GetString("akka.cluster.split-brain-resolver.active-strategy"));
}
/// <summary>Verifies that a hostname containing a quote does not corrupt the HOCON document.</summary>
[Fact]
public void BuildHocon_HostnameWithQuote_DoesNotCorruptDocument()
{
@@ -85,6 +88,7 @@ public class HoconBuilderTests
Assert.Equal(1, config.GetInt("akka.cluster.min-nr-of-members"));
}
/// <summary>Verifies that a strategy value containing whitespace remains quoted.</summary>
[Fact]
public void BuildHocon_StrategyWithWhitespace_RemainsQuoted()
{
@@ -112,10 +116,11 @@ public class HoconBuilderTests
RemotingPort = 8081,
};
/// <summary>Verifies that setting DownIfAlone to true emits the on token.</summary>
[Fact]
public void BuildHocon_DownIfAloneTrue_EmitsOn()
{
// Host-012: BuildHocon must consume ClusterOptions.DownIfAlone, not
// BuildHocon must consume ClusterOptions.DownIfAlone, not
// hard-code the down-if-alone token.
var cluster = DefaultCluster();
cluster.DownIfAlone = true;
@@ -129,10 +134,11 @@ public class HoconBuilderTests
"akka.cluster.split-brain-resolver.keep-oldest.down-if-alone"));
}
/// <summary>Verifies that setting DownIfAlone to false emits the off token.</summary>
[Fact]
public void BuildHocon_DownIfAloneFalse_EmitsOff()
{
// Host-012: setting DownIfAlone=false must actually flip the emitted token.
// Setting DownIfAlone=false must actually flip the emitted token.
var cluster = DefaultCluster();
cluster.DownIfAlone = false;
@@ -145,10 +151,11 @@ public class HoconBuilderTests
"akka.cluster.split-brain-resolver.keep-oldest.down-if-alone"));
}
/// <summary>Verifies that sub-second timings are preserved without rounding.</summary>
[Fact]
public void BuildHocon_SubSecondTimings_PreservedWithoutRounding()
{
// Host-013: cluster timing values below one second must not be rounded to
// Cluster timing values below one second must not be rounded to
// whole seconds. A 750ms heartbeat must survive as 750ms, not collapse to
// 1s — and a 500ms value must not collapse to a degenerate 0s.
var cluster = DefaultCluster();
@@ -178,6 +185,7 @@ public class HoconBuilderTests
config.GetTimeSpan("akka.remote.transport-failure-detector.acceptable-heartbeat-pause"));
}
/// <summary>Verifies that the document enables the split-brain resolver downing provider.</summary>
[Fact]
public void BuildHocon_EnablesSplitBrainResolverDowningProvider()
{
@@ -196,6 +204,7 @@ public class HoconBuilderTests
config.GetString("akka.cluster.downing-provider-class"));
}
/// <summary>Verifies that the auto-down strategy emits the auto-downing provider.</summary>
[Fact]
public void BuildHocon_AutoDownStrategy_EmitsAutoDowningProvider()
{
@@ -222,6 +231,7 @@ public class HoconBuilderTests
Assert.False(config.HasPath("akka.cluster.split-brain-resolver.active-strategy"));
}
/// <summary>Verifies that the auto-down strategy name is case-insensitive and the document stays intact.</summary>
[Fact]
public void BuildHocon_AutoDownStrategy_IsCaseInsensitive_AndDocumentStaysIntact()
{
@@ -14,6 +14,7 @@ public class HostStartupTests : IDisposable
{
private readonly List<IDisposable> _disposables = new();
/// <summary>Releases the resources held by this test class.</summary>
public void Dispose()
{
foreach (var d in _disposables)
@@ -22,6 +23,7 @@ public class HostStartupTests : IDisposable
}
}
/// <summary>Verifies that the Central role starts without error.</summary>
[Fact]
public void CentralRole_StartsWithoutError()
{
@@ -29,7 +31,7 @@ public class HostStartupTests : IDisposable
// 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.
// Connection strings are externalised; supply them via env vars.
using var dbEnv = new CentralDbTestEnvironment();
try
{
@@ -68,6 +70,7 @@ public class HostStartupTests : IDisposable
}
}
/// <summary>Verifies that the Site role starts without error.</summary>
[Fact]
public void SiteRole_StartsWithoutError()
{
@@ -98,6 +101,7 @@ public class HostStartupTests : IDisposable
Assert.NotNull(app.Services);
}
/// <summary>Verifies that the Site role configures Kestrel for gRPC.</summary>
[Fact]
public void SiteRole_ConfiguresKestrelForGrpc()
{
@@ -143,6 +147,7 @@ public class HostStartupTests : IDisposable
(app as IDisposable)?.Dispose();
}
/// <summary>Verifies that the Host project does not use conditional compilation.</summary>
[Fact]
public void HostProject_DoesNotUseConditionalCompilation()
{
@@ -165,6 +170,7 @@ public class HostStartupTests : IDisposable
}
}
/// <summary>Verifies that the program registers an unhandled-exception exit handler.</summary>
[Fact]
public void Program_RegistersUnhandledExceptionExitHandler()
{
@@ -5,7 +5,7 @@ using ZB.MOM.WW.ScadaBridge.Host.Services;
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
/// <summary>
/// #05-T14: unit tests for the in-process script-artifact change bus — delivery,
/// Unit tests for the in-process script-artifact change bus — delivery,
/// unsubscribe, and the swallow-and-log guard that keeps a faulting subscriber from
/// faulting the producer.
/// </summary>
@@ -17,6 +17,7 @@ public sealed class InProcessScriptArtifactChangeBusTests
private static ScriptArtifactsChanged Sample(string kind = ScriptArtifactKinds.ApiMethod) =>
new(kind, new[] { "M1" }, "Management", DateTimeOffset.UnixEpoch);
/// <summary>Verifies that publish delivers to all current subscribers.</summary>
[Fact]
public void Publish_delivers_to_all_current_subscribers()
{
@@ -33,6 +34,7 @@ public sealed class InProcessScriptArtifactChangeBusTests
Assert.Same(n, Assert.Single(b));
}
/// <summary>Verifies that disposing subscription stops delivery.</summary>
[Fact]
public void Disposing_subscription_stops_delivery()
{
@@ -47,6 +49,7 @@ public sealed class InProcessScriptArtifactChangeBusTests
Assert.Single(received); // only the pre-dispose publish landed.
}
/// <summary>Verifies that dispose is idempotent.</summary>
[Fact]
public void Dispose_is_idempotent()
{
@@ -61,6 +64,7 @@ public sealed class InProcessScriptArtifactChangeBusTests
Assert.Empty(received);
}
/// <summary>Verifies that a faulting subscriber does not prevent delivery to others nor throw.</summary>
[Fact]
public void A_faulting_subscriber_does_not_prevent_delivery_to_others_nor_throw()
{
@@ -76,6 +80,7 @@ public sealed class InProcessScriptArtifactChangeBusTests
Assert.Single(good);
}
/// <summary>Verifies that publish with no subscribers is a noop.</summary>
[Fact]
public void Publish_with_no_subscribers_is_a_noop()
{
@@ -17,12 +17,14 @@ public sealed class InboundApiKeyStoreImportServiceTests : IDisposable
{
private readonly string _dir;
/// <summary>Initializes the tests with a fresh temporary directory.</summary>
public InboundApiKeyStoreImportServiceTests()
{
_dir = Path.Combine(Path.GetTempPath(), $"sb_apikey_import_{Guid.NewGuid():N}");
Directory.CreateDirectory(_dir);
}
/// <summary>Releases the resources held by the test fixture.</summary>
public void Dispose()
{
try
@@ -35,6 +37,8 @@ public sealed class InboundApiKeyStoreImportServiceTests : IDisposable
}
}
/// <summary>Verifies that a present file is imported and then renamed along with its sidecar files.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task StartAsync_FilePresent_ImportsThenRenamesFileAndSidecars()
{
@@ -56,6 +60,8 @@ public sealed class InboundApiKeyStoreImportServiceTests : IDisposable
Assert.True(File.Exists(path + "-shm.imported"));
}
/// <summary>Verifies that the importer is not called when the file is absent.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task StartAsync_FileAbsent_DoesNotCallImporter()
{
@@ -67,6 +73,8 @@ public sealed class InboundApiKeyStoreImportServiceTests : IDisposable
Assert.Empty(importer.Calls);
}
/// <summary>Verifies that the importer is not called when the configured path is blank.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task StartAsync_BlankPath_DoesNotCallImporter()
{
@@ -78,6 +86,8 @@ public sealed class InboundApiKeyStoreImportServiceTests : IDisposable
Assert.Empty(importer.Calls);
}
/// <summary>Verifies that the import-from path overrides the path in the API key options.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task StartAsync_ImportFromSqlitePath_OverridesApiKeyOptionsPath()
{
@@ -94,6 +104,8 @@ public sealed class InboundApiKeyStoreImportServiceTests : IDisposable
Assert.True(File.Exists(overridePath + ".imported"));
}
/// <summary>Verifies that an importer failure does not throw and leaves the file in place.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task StartAsync_ImporterThrows_DoesNotThrowAndLeavesFileInPlace()
{
@@ -109,6 +121,8 @@ public sealed class InboundApiKeyStoreImportServiceTests : IDisposable
Assert.False(File.Exists(path + ".imported"));
}
/// <summary>Verifies that a primary key violation is retried once and then succeeds.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task StartAsync_PrimaryKeyViolation_RetriesOnceThenSucceeds()
{
@@ -126,6 +140,8 @@ public sealed class InboundApiKeyStoreImportServiceTests : IDisposable
Assert.True(File.Exists(path + ".imported"));
}
/// <summary>Verifies that two consecutive primary key violations do not throw and leave the file in place.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task StartAsync_PrimaryKeyViolationTwice_DoesNotThrowAndLeavesFileInPlace()
{
@@ -143,6 +159,8 @@ public sealed class InboundApiKeyStoreImportServiceTests : IDisposable
Assert.True(File.Exists(path));
}
/// <summary>Verifies that a SQL exception other than a primary key violation is not retried.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task StartAsync_NonPrimaryKeySqlException_DoesNotRetry()
{
@@ -160,6 +178,8 @@ public sealed class InboundApiKeyStoreImportServiceTests : IDisposable
Assert.True(File.Exists(path));
}
/// <summary>Verifies that a stale previously imported file is overwritten by the rename.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task StartAsync_StaleImportedFilePresent_IsOverwritten()
{
@@ -175,6 +195,8 @@ public sealed class InboundApiKeyStoreImportServiceTests : IDisposable
Assert.Equal("fresh", File.ReadAllText(path + ".imported"));
}
/// <summary>Verifies that stopping the service does nothing.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task StopAsync_IsANoOp()
{
@@ -211,11 +233,13 @@ public sealed class InboundApiKeyStoreImportServiceTests : IDisposable
{
private int _calls;
/// <summary>Gets the SQLite paths the importer was called with, in call order.</summary>
public List<string> Calls { get; } = new();
/// <summary>Given the 1-based call number, returns the exception to throw, or null to succeed.</summary>
public Func<int, Exception?>? ThrowOnCall { get; init; }
/// <inheritdoc />
public Task<ApiKeyImportResult> ImportAsync(string sqlitePath, CancellationToken ct)
{
Calls.Add(sqlitePath);
@@ -11,6 +11,7 @@ public class InboundApiKeyStoreOptionsTests
.Build();
}
/// <summary>Verifies that the key store provider defaults to SQLite.</summary>
[Fact]
public void Provider_DefaultsToSqlite()
{
@@ -21,6 +22,9 @@ public class InboundApiKeyStoreOptionsTests
Assert.Equal(InboundApiKeyStoreProvider.Sqlite, InboundApiKeyStoreOptions.ReadProvider(configuration));
}
/// <summary>Verifies that the provider name is parsed case-insensitively.</summary>
/// <param name="value">The configured provider text.</param>
/// <param name="expected">The provider the text should resolve to.</param>
[Theory]
[InlineData("sqlserver", InboundApiKeyStoreProvider.SqlServer)]
[InlineData("SQLSERVER", InboundApiKeyStoreProvider.SqlServer)]
@@ -36,6 +40,8 @@ public class InboundApiKeyStoreOptionsTests
Assert.Equal(expected, InboundApiKeyStoreOptions.ReadProvider(configuration));
}
/// <summary>Verifies that an unknown provider value is rejected.</summary>
/// <param name="value">The unsupported provider text.</param>
[Theory]
[InlineData("Postgres")]
[InlineData("7")]
@@ -52,6 +58,7 @@ public class InboundApiKeyStoreOptionsTests
Assert.Contains("SqlServer", ex.Message);
}
/// <summary>Verifies that the import-from-SQLite path setting binds from configuration.</summary>
[Fact]
public void Binds_ImportFromSqlitePath()
{
@@ -17,6 +17,11 @@ namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
public class InboundBodyLimitKestrelTests
{
/// <summary>Verifies that an over-limit request body is rejected by the Kestrel cap regardless of audit body-capture settings.</summary>
/// <param name="http2">Whether the request uses HTTP/2 instead of HTTP/1.1.</param>
/// <param name="skipCapture">Whether the audit per-target override skips body capture.</param>
/// <param name="declared">Whether the request declares its content length.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory]
[InlineData(false, false, false)]
[InlineData(false, true, false)]
@@ -55,7 +60,9 @@ public class InboundBodyLimitKestrelTests
private sealed class UnknownLengthContent(byte[] bytes) : HttpContent
{
/// <inheritdoc />
protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) => stream.WriteAsync(bytes).AsTask();
/// <inheritdoc />
protected override bool TryComputeLength(out long length) { length = 0; return false; }
}
}
@@ -6,7 +6,7 @@ using ZB.MOM.WW.ScadaBridge.SiteCallAudit;
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
/// <summary>
/// P5-5 (arch-review Low): <see cref="KpiRateFoldIntervalCrossValidator"/> is the
/// <see cref="KpiRateFoldIntervalCrossValidator"/> is the
/// boot-time guardrail for the "rate-fold interval triple" —
/// <see cref="KpiHistoryOptions.SampleInterval"/>,
/// <see cref="NotificationOutboxOptions.DeliveredKpiWindow"/>, and
@@ -30,6 +30,7 @@ public class KpiRateFoldIntervalCrossValidatorTests
return validator.Validate(Options.DefaultName, kpiHistoryOptions);
}
/// <summary>Verifies that the validator accepts all three values being equal at the documented defaults.</summary>
[Fact]
public void AllThreeEqual_AtDocumentedDefaults_IsValid()
{
@@ -39,6 +40,7 @@ public class KpiRateFoldIntervalCrossValidatorTests
Assert.True(result.Succeeded, result.FailureMessage);
}
/// <summary>Verifies that the validator accepts all three values being equal at a common non-default value.</summary>
[Fact]
public void AllThreeEqual_AtANonDefaultCommonValue_IsValid()
{
@@ -51,6 +53,7 @@ public class KpiRateFoldIntervalCrossValidatorTests
Assert.True(result.Succeeded, result.FailureMessage);
}
/// <summary>Verifies that the validator fails and names all three keys when the delivered KPI window diverges.</summary>
[Fact]
public void DeliveredKpiWindowDiverges_FailsAndNamesAllThreeKeys()
{
@@ -65,6 +68,7 @@ public class KpiRateFoldIntervalCrossValidatorTests
Assert.Contains("ScadaBridge:SiteCallAudit:KpiInterval", result.FailureMessage);
}
/// <summary>Verifies that the validator fails and names all three keys when the KPI interval diverges.</summary>
[Fact]
public void KpiIntervalDiverges_FailsAndNamesAllThreeKeys()
{
@@ -79,6 +83,7 @@ public class KpiRateFoldIntervalCrossValidatorTests
Assert.Contains("ScadaBridge:SiteCallAudit:KpiInterval", result.FailureMessage);
}
/// <summary>Verifies that the validator fails when both the delivered KPI window and the KPI interval diverge.</summary>
[Fact]
public void BothDeliveredKpiWindowAndKpiIntervalDiverge_Fails()
{
@@ -7,7 +7,7 @@ using ZB.MOM.WW.ScadaBridge.Host;
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
/// <summary>
/// LocalDb Phase 1 (Task 8) — the passive sync endpoint's inbound gate.
/// LocalDb Phase 1 — the passive sync endpoint's inbound gate.
/// </summary>
/// <remarks>
/// The replication library's <c>LocalDbSyncService</c> verifies nothing; inbound auth is
@@ -47,22 +47,34 @@ public class LocalDbSyncAuthInterceptorTests
private sealed class FakeServerCallContext(string method, Metadata requestHeaders)
: ServerCallContext
{
/// <inheritdoc />
protected override string MethodCore => method;
/// <inheritdoc />
protected override string HostCore => "localhost";
/// <inheritdoc />
protected override string PeerCore => "ipv4:127.0.0.1:12345";
/// <inheritdoc />
protected override DateTime DeadlineCore => DateTime.UtcNow.AddMinutes(1);
/// <inheritdoc />
protected override Metadata RequestHeadersCore => requestHeaders;
/// <inheritdoc />
protected override CancellationToken CancellationTokenCore => CancellationToken.None;
/// <inheritdoc />
protected override Metadata ResponseTrailersCore { get; } = [];
/// <inheritdoc />
protected override Status StatusCore { get; set; }
/// <inheritdoc />
protected override WriteOptions? WriteOptionsCore { get; set; }
/// <inheritdoc />
protected override AuthContext AuthContextCore { get; } =
new(null, new Dictionary<string, List<AuthProperty>>());
/// <inheritdoc />
protected override ContextPropagationToken CreatePropagationTokenCore(
ContextPropagationOptions? options)
=> throw new NotSupportedException();
/// <inheritdoc />
protected override Task WriteResponseHeadersAsyncCore(Metadata responseHeaders)
=> Task.CompletedTask;
}
@@ -73,6 +85,8 @@ public class LocalDbSyncAuthInterceptorTests
=> interceptor.UnaryServerHandler<string, string>(
"request", context, (_, _) => Task.FromResult("ok"));
/// <summary>Verifies that a non-sync method passes through even when no key is configured.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task NonSyncMethod_PassesThrough_EvenWithNoKeyConfigured()
{
@@ -85,6 +99,8 @@ public class LocalDbSyncAuthInterceptorTests
Assert.Equal("ok", await Invoke(interceptor, context));
}
/// <summary>Verifies that a sync method is denied when no key is configured, even with a bearer token.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SyncMethod_WithNoKeyConfigured_IsDenied_EvenWithABearerToken()
{
@@ -98,6 +114,8 @@ public class LocalDbSyncAuthInterceptorTests
Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode);
}
/// <summary>Verifies that a sync method without a bearer token is denied.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SyncMethod_WithNoBearerToken_IsDenied()
{
@@ -108,6 +126,8 @@ public class LocalDbSyncAuthInterceptorTests
Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode);
}
/// <summary>Verifies that a sync method with the wrong bearer token is denied.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SyncMethod_WithWrongBearerToken_IsDenied()
{
@@ -118,6 +138,8 @@ public class LocalDbSyncAuthInterceptorTests
Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode);
}
/// <summary>Verifies that a sync method with the correct bearer token passes through.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SyncMethod_WithCorrectBearerToken_PassesThrough()
{
@@ -127,6 +149,8 @@ public class LocalDbSyncAuthInterceptorTests
Assert.Equal("ok", await Invoke(interceptor, context));
}
/// <summary>Verifies that a sync method with the correct key but no Bearer scheme is denied.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SyncMethod_WithCorrectKey_ButNoBearerScheme_IsDenied()
{
@@ -139,6 +163,8 @@ public class LocalDbSyncAuthInterceptorTests
Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode);
}
/// <summary>Verifies that the token comparison is not a prefix match.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SyncMethod_TokenComparison_IsNotAPrefixMatch()
{
@@ -151,6 +177,8 @@ public class LocalDbSyncAuthInterceptorTests
Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode);
}
/// <summary>Verifies that duplex streaming calls are gated, because that is how sync actually runs.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DuplexStreaming_IsGated_BecauseThatIsHowSyncActuallyRuns()
{
@@ -4,7 +4,7 @@ using Serilog.Events;
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
/// <summary>
/// Host-011: <c>ScadaBridge:Logging:MinimumLevel</c> must actually drive the Serilog
/// <c>ScadaBridge:Logging:MinimumLevel</c> must actually drive the Serilog
/// minimum level. Previously the value was bound into <see cref="LoggingOptions"/>
/// but never read, so editing it had no effect.
/// </summary>
@@ -18,6 +18,7 @@ public class LoggerConfigurationTests
return new ConfigurationBuilder().AddInMemoryCollection(values).Build();
}
/// <summary>Verifies that a Warning minimum level suppresses Information logs.</summary>
[Fact]
public void MinimumLevel_Warning_SuppressesInformationLogs()
{
@@ -34,6 +35,7 @@ public class LoggerConfigurationTests
Assert.Equal(LogEventLevel.Warning, sink.LogEvents[0].Level);
}
/// <summary>Verifies that a Debug minimum level allows Debug logs.</summary>
[Fact]
public void MinimumLevel_Debug_AllowsDebugLogs()
{
@@ -49,6 +51,7 @@ public class LoggerConfigurationTests
Assert.Equal(LogEventLevel.Debug, sink.LogEvents[0].Level);
}
/// <summary>Verifies that an absent minimum level defaults to Information.</summary>
[Fact]
public void MinimumLevel_Absent_DefaultsToInformation()
{
@@ -66,7 +69,7 @@ public class LoggerConfigurationTests
}
/// <summary>
/// Host-022: an unrecognised <c>ScadaBridge:Logging:MinimumLevel</c> (e.g. a typo
/// An unrecognised <c>ScadaBridge:Logging:MinimumLevel</c> (e.g. a typo
/// like "Informaiton") must NOT abort startup but MUST emit a one-shot warning
/// naming the offending value and the fallback so the silent coercion is
/// visible. Null/blank is treated as "unset" and silently defaults.
@@ -85,6 +88,7 @@ public class LoggerConfigurationTests
Assert.Contains("Information", warning);
}
/// <summary>Verifies that a null or blank level falls back silently.</summary>
[Fact]
public void ParseLevel_NullOrBlank_FallsBackSilently()
{
@@ -98,6 +102,7 @@ public class LoggerConfigurationTests
Assert.Empty(writer.ToString());
}
/// <summary>Verifies that a recognised level value produces no warning.</summary>
[Fact]
public void ParseLevel_RecognisedValue_NoWarning()
{
@@ -110,7 +115,7 @@ public class LoggerConfigurationTests
}
/// <summary>
/// Host-020: <c>ScadaBridge:Logging:MinimumLevel</c> is the documented source
/// <c>ScadaBridge:Logging:MinimumLevel</c> is the documented source
/// of truth for the Serilog floor, and the explicit <c>MinimumLevel.Is</c>
/// call deliberately runs after <c>ReadFrom.Configuration(...)</c> so a
/// <c>Serilog:MinimumLevel</c> entry is overridden. To make that precedence
@@ -141,6 +146,7 @@ public class LoggerConfigurationTests
Assert.Contains("Warning", warning);
}
/// <summary>Verifies that setting only the ScadaBridge minimum level produces no override warning.</summary>
[Fact]
public void Build_OnlyScadaBridgeMinimumLevelSet_NoOverrideWarning()
{
@@ -13,6 +13,8 @@ namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
/// <summary>Every actually mapped generated gRPC method must have an authentication gate.</summary>
public class MappedGrpcAuthenticationCoverageTests
{
/// <summary>Verifies every mapped grpc service is covered including external mappings.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task EveryMappedGrpcService_IsCoveredIncludingExternalMappings()
{
@@ -51,7 +53,7 @@ public class MappedGrpcAuthenticationCoverageTests
Assert.True(serviceTypes.Count >= typeNames.Count + 1, "The external LocalDb mapping must also be exercised.");
// The secrets hub — which used to install an all-method interceptor on its own service
// options and so needed a separate gate check here — is retired (Task 11): there is no
// options and so needed a separate gate check here — is retired: there is no
// longer a mapped gRPC service whose only gate is anything other than one of these prefixes.
var prefixes = ControlPlaneAuthInterceptor.DefaultGatedPrefixes
.Concat(CentralControlAuthInterceptor.DefaultGatedPrefixes)
@@ -89,13 +91,18 @@ public class MappedGrpcAuthenticationCoverageTests
private sealed class MethodCollector : ServiceBinderBase
{
/// <summary>Gets the full names of the gRPC methods registered with the fake binder.</summary>
public List<string> Methods { get; } = [];
/// <inheritdoc />
public override void AddMethod<TRequest, TResponse>(Method<TRequest, TResponse> method, UnaryServerMethod<TRequest, TResponse>? handler)
=> Methods.Add(method.FullName);
/// <inheritdoc />
public override void AddMethod<TRequest, TResponse>(Method<TRequest, TResponse> method, ClientStreamingServerMethod<TRequest, TResponse>? handler)
=> Methods.Add(method.FullName);
/// <inheritdoc />
public override void AddMethod<TRequest, TResponse>(Method<TRequest, TResponse> method, ServerStreamingServerMethod<TRequest, TResponse>? handler)
=> Methods.Add(method.FullName);
/// <inheritdoc />
public override void AddMethod<TRequest, TResponse>(Method<TRequest, TResponse> method, DuplexStreamingServerMethod<TRequest, TResponse>? handler)
=> Methods.Add(method.FullName);
}
@@ -18,12 +18,14 @@ public class MetricsEndpointTests : IDisposable
{
private readonly List<IDisposable> _disposables = new();
/// <summary>Initializes a new instance of the <c>MetricsEndpointTests</c> class.</summary>
public MetricsEndpointTests()
{
// Host-003: connection strings are externalised; supply them via env vars.
// Connection strings are externalised; supply them via env vars.
_disposables.Add(new CentralDbTestEnvironment());
}
/// <summary>Releases the resources held by the test fixture.</summary>
public void Dispose()
{
foreach (var d in _disposables)
@@ -56,6 +58,8 @@ public class MetricsEndpointTests : IDisposable
return factory;
}
/// <summary>Verifies that the metrics endpoint is mapped.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Metrics_Endpoint_IsMapped()
{
@@ -16,6 +16,7 @@ public class NodeIdentityProviderTests
return new NodeIdentityProvider(options);
}
/// <summary>Verifies that the provider returns the configured node name.</summary>
[Fact]
public void NodeIdentityProvider_returns_configured_NodeName()
{
@@ -24,6 +25,7 @@ public class NodeIdentityProviderTests
Assert.Equal("central-a", provider.NodeName);
}
/// <summary>Verifies that the provider returns null when the node name is not set.</summary>
[Fact]
public void NodeIdentityProvider_returns_null_when_NodeName_unset()
{
@@ -32,6 +34,7 @@ public class NodeIdentityProviderTests
Assert.Null(provider.NodeName);
}
/// <summary>Verifies that the provider returns null when the node name is only whitespace.</summary>
[Fact]
public void NodeIdentityProvider_returns_null_when_NodeName_whitespace()
{
@@ -40,6 +43,7 @@ public class NodeIdentityProviderTests
Assert.Null(provider.NodeName);
}
/// <summary>Verifies that the provider trims surrounding whitespace from the node name.</summary>
[Fact]
public void NodeIdentityProvider_trims_whitespace()
{
@@ -15,6 +15,7 @@ public class NodeOptionsValidatorTests
{
// ---- NodeOptions ----
/// <summary>Verifies that default node options with a node name are valid.</summary>
[Fact]
public void Node_DefaultsWithNodeName_AreValid()
{
@@ -22,6 +23,8 @@ public class NodeOptionsValidatorTests
Assert.True(validator.Validate(null, new NodeOptions { NodeName = "node-a" }).Succeeded);
}
/// <summary>Verifies that an empty or whitespace node name fails validation.</summary>
/// <param name="nodeName">The invalid node name.</param>
[Theory]
[InlineData("")]
[InlineData(" ")]
@@ -34,6 +37,7 @@ public class NodeOptionsValidatorTests
f => f.Contains(nameof(NodeOptions.NodeName), StringComparison.Ordinal));
}
/// <summary>Verifies that a remoting port of zero is valid.</summary>
[Fact]
public void Node_ZeroRemotingPort_IsValid()
{
@@ -43,6 +47,8 @@ public class NodeOptionsValidatorTests
new NodeOptions { NodeName = "node-a", RemotingPort = 0 }).Succeeded);
}
/// <summary>Verifies that a remoting port outside the valid range fails validation.</summary>
/// <param name="port">The out-of-range port number.</param>
[Theory]
[InlineData(-1)]
[InlineData(65_536)]
@@ -58,6 +64,7 @@ public class NodeOptionsValidatorTests
// ---- DatabaseOptions ----
/// <summary>Verifies that database options with all values null are valid.</summary>
[Fact]
public void Database_AllNull_IsValid()
{
@@ -66,6 +73,7 @@ public class NodeOptionsValidatorTests
Assert.True(validator.Validate(null, new DatabaseOptions()).Succeeded);
}
/// <summary>Verifies that a whitespace database value fails validation.</summary>
[Fact]
public void Database_WhitespaceValue_Fails()
{
@@ -78,6 +86,7 @@ public class NodeOptionsValidatorTests
// ---- LoggingOptions ----
/// <summary>Verifies that the default Information logging level is valid.</summary>
[Fact]
public void Logging_DefaultInformation_IsValid()
{
@@ -85,6 +94,8 @@ public class NodeOptionsValidatorTests
Assert.True(validator.Validate(null, new LoggingOptions()).Succeeded);
}
/// <summary>Verifies that a recognised logging level is valid.</summary>
/// <param name="level">The logging level name.</param>
[Theory]
[InlineData("Verbose")]
[InlineData("debug")]
@@ -95,6 +106,7 @@ public class NodeOptionsValidatorTests
Assert.True(validator.Validate(null, new LoggingOptions { MinimumLevel = level }).Succeeded);
}
/// <summary>Verifies that an unknown logging level fails validation.</summary>
[Fact]
public void Logging_UnknownLevel_Fails()
{
@@ -78,6 +78,8 @@ public class PreHostSecretsContentRootTests
+ string.Join(", ", leftovers));
}
/// <summary>Verifies, as a control, that a valid path actually creates a secrets store.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Control_ValidPath_ActuallyCreatesAStore()
{
@@ -93,6 +95,8 @@ public class PreHostSecretsContentRootTests
Assert.True(File.Exists(path), "the control must create a store, or the guards prove nothing");
}
/// <summary>Verifies that a path inside the content root is rejected and leaves no store behind.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PathInsideContentRoot_IsRejected_AndLeavesNoStore()
{
@@ -105,6 +109,8 @@ public class PreHostSecretsContentRootTests
Assert.NotNull(thrown);
}
/// <summary>Verifies that a relative path is rejected and leaves no store behind.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RelativePath_IsRejected_AndLeavesNoStore()
{
@@ -8,7 +8,7 @@ using ZB.MOM.WW.ScadaBridge.Host.Health;
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
/// <summary>
/// M2.14 (#28): unit tests for <see cref="RequiredSingletonsHealthCheck"/>.
/// Unit tests for <see cref="RequiredSingletonsHealthCheck"/>.
///
/// The check probes each required central singleton through its local
/// <c>ClusterSingletonProxy</c> by Asking an <see cref="Identify"/> with a short
@@ -52,6 +52,8 @@ public class RequiredSingletonsHealthCheckTests : TestKit
return await check.CheckHealthAsync(context, CancellationToken.None);
}
/// <summary>Verifies all required singleton proxies reachable reports healthy.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AllRequiredSingletonProxiesReachable_ReportsHealthy()
{
@@ -71,6 +73,8 @@ public class RequiredSingletonsHealthCheckTests : TestKit
Assert.Equal(HealthStatus.Healthy, result.Status);
}
/// <summary>Verifies that an unreachable required singleton makes the check report unhealthy and name it.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task OneRequiredSingletonUnreachable_ReportsUnhealthyNamingIt()
{
@@ -95,6 +99,8 @@ public class RequiredSingletonsHealthCheckTests : TestKit
Assert.Contains(missing, result.Description!);
}
/// <summary>Verifies that an actor system that is not yet available makes the check report unhealthy without throwing.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ActorSystemNotYetAvailable_ReportsUnhealthy_DoesNotThrow()
{
@@ -111,6 +117,8 @@ public class RequiredSingletonsHealthCheckTests : TestKit
Assert.Equal(HealthStatus.Unhealthy, result.Status);
}
/// <summary>Verifies that an already-cancelled token makes the check report unhealthy without throwing.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PreCancelledToken_ReportsUnhealthy_DoesNotThrow()
{
@@ -9,6 +9,8 @@ namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
/// </summary>
public class ScadaBridgeTelemetryTests
{
/// <summary>Verifies that the meter name is a stable value.</summary>
/// <returns>The result.</returns>
[Fact]
public void MeterName_IsStableValue()
{
@@ -7,14 +7,14 @@ using ZB.MOM.WW.Secrets.Ui;
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
/// <summary>
/// G-6 (Task 7) offline behavioral proof: composing the Secrets.Ui authorization
/// Offline behavioral proof: composing the Secrets.Ui authorization
/// (<c>Configure&lt;AuthorizationOptions&gt;(o =&gt; o.AddSecretsAuthorization())</c>) on top of
/// ScadaBridge's own <see cref="AuthorizationPolicies.AddScadaBridgeAuthorization"/> yields the
/// two named policies <c>secrets:manage</c> + <c>secrets:reveal</c>, and — because ScadaBridge
/// builds identities with <c>RoleClaimType = ZbClaimTypes.Role</c> and the library's admin role is
/// literally <c>"Administrator"</c> — an <c>Administrator</c> principal is authorized for BOTH,
/// while a non-admin / anonymous principal is denied BOTH. This is the offline evidence for the
/// Task-6 "no app-specific policy remap needed" decision, exercised against the real
/// "no app-specific policy remap needed" decision, exercised against the real
/// <see cref="IAuthorizationService"/> rather than mere registration.
/// </summary>
public class SecretsAuthorizationTests
@@ -47,6 +47,8 @@ public class SecretsAuthorizationTests
return new ClaimsPrincipal(identity);
}
/// <summary>Verifies both secrets policies are registered.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task BothSecretsPolicies_AreRegistered()
{
@@ -57,6 +59,8 @@ public class SecretsAuthorizationTests
Assert.NotNull(await policyProvider.GetPolicyAsync(RevealPolicy));
}
/// <summary>Verifies that an administrator is authorized for both manage and reveal.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Administrator_IsAuthorized_ForBothManageAndReveal()
{
@@ -68,6 +72,8 @@ public class SecretsAuthorizationTests
Assert.True((await authService.AuthorizeAsync(admin, RevealPolicy)).Succeeded);
}
/// <summary>Verifies that a non-administrator role is denied both manage and reveal.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task NonAdministratorRole_IsDenied_ForBothManageAndReveal()
{
@@ -79,6 +85,8 @@ public class SecretsAuthorizationTests
Assert.False((await authService.AuthorizeAsync(viewer, RevealPolicy)).Succeeded);
}
/// <summary>Verifies that an anonymous principal is denied both manage and reveal.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AnonymousPrincipal_IsDenied_ForBothManageAndReveal()
{
@@ -9,10 +9,10 @@ namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
/// <summary>
/// DI-resolution tests for <see cref="SecretsRegistration.AddScadaBridgeSecrets"/>.
/// <para>
/// Task 11 retired every secrets REPLICATION mode in ScadaBridge — the pull-only gRPC hub (central
/// Every secrets REPLICATION mode is retired in ScadaBridge — the pull-only gRPC hub (central
/// hosts, sites sweep) and the SQL-Server <c>ReplicatingSecretStore</c> mode that preceded it — on
/// BOTH roles. What is left: the plain local SQLite store, which is every node's default and now a
/// Site's ONLY possible shape, and the still-supported Task 5
/// Site's ONLY possible shape, and the still-supported
/// <see cref="SecretsRegistration.StoreProviderKey"/>=SqlServer direct shared store (Central-only,
/// no hub, no follower sweep — just two nodes reading and writing one database).
/// </para>
@@ -92,12 +92,13 @@ public class SecretsReplicationWiringTests
// ---------------------------------------------------------------------------------------
// Site: the ONLY shape left, on every configuration — plain local SQLite, never a
// replication decorator (Task 11, ruling #1). DeployedSecretsMirror deletes a name from
// replication decorator. DeployedSecretsMirror deletes a name from
// this store directly the moment a deploy artifact stops referencing it; a decorator would
// publish that delete to every other node sharing whatever it decorates against, turning
// one site's unreferencing of a secret into a fleet-wide deletion.
// ---------------------------------------------------------------------------------------
/// <summary>Verifies that a site resolves the plain SQLite secret store and never a replication decorator.</summary>
[Fact]
public void Site_ResolvesPlainSqliteStore_NeverAReplicationDecorator()
{
@@ -110,6 +111,7 @@ public class SecretsReplicationWiringTests
Assert.IsType<SqliteSecretStore>(store);
}
/// <summary>Verifies that a site ignores the store provider setting even when it is set to SqlServer.</summary>
[Fact]
public void Site_IgnoresStoreProvider_EvenSetToSqlServer()
{
@@ -123,6 +125,7 @@ public class SecretsReplicationWiringTests
Assert.IsType<SqliteSecretStore>(provider.GetRequiredService<ISecretStore>());
}
/// <summary>Verifies that the site container holds no SQL Server secret-store package types.</summary>
[Fact]
public void Site_HasNoSqlServerPackageTypesInTheContainer()
{
@@ -145,6 +148,7 @@ public class SecretsReplicationWiringTests
// Central, default — plain local SQLite, the shape every existing deployment is in.
// ---------------------------------------------------------------------------------------
/// <summary>Verifies that central defaults to the plain SQLite secret store.</summary>
[Fact]
public void Central_DefaultsToPlainSqliteStore()
{
@@ -153,6 +157,7 @@ public class SecretsReplicationWiringTests
Assert.IsType<SqliteSecretStore>(provider.GetRequiredService<ISecretStore>());
}
/// <summary>Verifies that setting the store provider to Sqlite behaves the same as leaving it unset on central.</summary>
[Fact]
public void Central_StoreProviderSqlite_IsIndistinguishableFromUnset()
{
@@ -166,10 +171,11 @@ public class SecretsReplicationWiringTests
// ---------------------------------------------------------------------------------------
// Secrets:Store:Provider=SqlServer — a clustered central sharing ONE SQL secret store
// directly, without a hub (task 5). The one replication-adjacent feature Task 11 left
// directly, without a hub. The one replication-adjacent feature that was left
// standing, because it is a plain shared database, not a converging replica set.
// ---------------------------------------------------------------------------------------
/// <summary>Verifies that central uses the shared SQL store when the provider is SqlServer.</summary>
[Fact]
public void Central_StoreProviderSqlServer_UsesSharedSqlStore()
{
@@ -182,6 +188,7 @@ public class SecretsReplicationWiringTests
Assert.IsType<SqlServerSecretStore>(provider.GetRequiredService<ISecretStore>());
}
/// <summary>Verifies that central throws when the provider is SqlServer and the connection string is blank.</summary>
[Fact]
public void Central_StoreProviderSqlServer_BlankConnectionString_Throws()
{
@@ -228,7 +235,7 @@ public class SecretsReplicationWiringTests
// ---------------------------------------------------------------------------------------
// The Layer-A pre-host ${secret:} expander (SecretsRegistration.AddPreHostExpander, called
// from Program.cs) — review round 1, Important #1. It MUST compose against the SAME store
// from Program.cs). It MUST compose against the SAME store
// AddScadaBridgeSecrets registers for the running node: an expander that resolved boot-time
// ${secret:} tokens (e.g. ScadaBridge:Security:JwtSigningKey) from the per-node SQLite file
// while the running node served the shared SQL store would let two central nodes disagree on
@@ -236,6 +243,7 @@ public class SecretsReplicationWiringTests
// UsesCentralSharedStore is the single predicate shared by both methods.
// ---------------------------------------------------------------------------------------
/// <summary>Verifies that the shared-store check is true only for central with the SqlServer provider.</summary>
[Fact]
public void UsesCentralSharedStore_TrueOnlyForCentralWithProviderSqlServer()
{
@@ -250,6 +258,7 @@ public class SecretsReplicationWiringTests
SecretsRegistration.UsesCentralSharedStore(sqliteConfig, SecretsNodeRole.Central));
}
/// <summary>Verifies that the central pre-host expander resolves the shared SQL store when the provider is SqlServer.</summary>
[Fact]
public void ExpanderCentral_StoreProviderSqlServer_ResolvesSharedSqlStore()
{
@@ -273,6 +282,7 @@ public class SecretsReplicationWiringTests
Assert.NotNull(provider.GetRequiredService<ISecretsStoreMigrator>());
}
/// <summary>Verifies that the central pre-host expander resolves the SQLite store by default.</summary>
[Fact]
public void ExpanderCentral_Default_ResolvesSqliteStore()
{
@@ -286,6 +296,7 @@ public class SecretsReplicationWiringTests
Assert.NotNull(provider.GetRequiredService<ISecretsStoreMigrator>());
}
/// <summary>Verifies that the site pre-host expander still resolves the SQLite store when the provider is SqlServer.</summary>
[Fact]
public void ExpanderSite_StoreProviderSqlServer_StillResolvesSqliteStore()
{
@@ -304,6 +315,7 @@ public class SecretsReplicationWiringTests
Assert.IsType<SqliteSecretStore>(provider.GetRequiredService<ISecretStore>());
}
/// <summary>Verifies that the central pre-host expander throws when the provider is SqlServer and the connection string is blank.</summary>
[Fact]
public void ExpanderCentral_StoreProviderSqlServer_BlankConnectionString_Throws()
{
@@ -321,6 +333,7 @@ public class SecretsReplicationWiringTests
$"{SecretsRegistration.StoreProviderKey}=SqlServer", ex.Message, StringComparison.Ordinal);
}
/// <summary>Verifies that the central pre-host expander throws when the SqlServer connection string is itself a secret reference.</summary>
[Fact]
public void ExpanderCentral_StoreProviderSqlServer_SecretReferenceConnectionString_Throws()
{
@@ -5,7 +5,7 @@ using Microsoft.Extensions.Configuration;
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
/// <summary>
/// Host-014 regression: REQ-HOST-8 requires the Host's Serilog sinks
/// Regression test: the Host requires its Serilog sinks
/// (console and file at minimum) to be <em>configuration-driven</em>. The sinks
/// must be defined in a <c>Serilog</c> section in <c>appsettings.json</c> and
/// applied via <c>ReadFrom.Configuration</c> — they must not be hard-coded in
@@ -28,6 +28,7 @@ public class SerilogSinkConfigTests
throw new DirectoryNotFoundException("Could not locate src/ZB.MOM.WW.ScadaBridge.Host");
}
/// <summary>Verifies that the shipped appsettings contain a Serilog section with console and file sinks.</summary>
[Fact]
public void ShippedAppSettings_HasSerilogSection_WithConsoleAndFileSinks()
{
@@ -51,6 +52,7 @@ public class SerilogSinkConfigTests
Assert.Contains("File", sinkNames);
}
/// <summary>Verifies that the logger configuration factory applies the configured file sink.</summary>
[Fact]
public void LoggerConfigurationFactory_AppliesConfiguredFileSink()
{
@@ -4,10 +4,11 @@ using Serilog.Events;
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
/// <summary>
/// WP-14: Tests for Serilog structured logging with enriched properties.
/// Tests for Serilog structured logging with enriched properties.
/// </summary>
public class SerilogTests
{
/// <summary>Verifies that the Serilog logger enriches events with node properties.</summary>
[Fact]
public void SerilogLogger_EnrichesWithNodeProperties()
{
@@ -35,6 +36,7 @@ public class SerilogTests
Assert.Equal("\"Site\"", logEvent.Properties["NodeRole"].ToString());
}
/// <summary>Verifies that the logger enriches events with a site id of Central for the central role.</summary>
[Fact]
public void SerilogLogger_CentralRole_EnrichesSiteIdAsCentral()
{
@@ -63,8 +65,11 @@ public class SerilogTests
/// </summary>
public class InMemorySink : Serilog.Core.ILogEventSink
{
/// <summary>Gets the log events captured so far.</summary>
public List<LogEvent> LogEvents { get; } = new();
/// <summary>Stores the log event for later assertions.</summary>
/// <param name="logEvent">The log event to capture.</param>
public void Emit(LogEvent logEvent)
{
LogEvents.Add(logEvent);
@@ -28,6 +28,8 @@ namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
/// </remarks>
public class ServiceProviderValidationOptionsTests
{
/// <summary>Verifies that applying the options in development enables both scope and build validation.</summary>
/// <returns>The result.</returns>
[Fact]
public void Apply_InDevelopment_EnablesBothValidations()
{
@@ -39,6 +41,8 @@ public class ServiceProviderValidationOptionsTests
Assert.True(options.ValidateOnBuild);
}
/// <summary>Verifies that applying the options outside development turns both validations off.</summary>
/// <returns>The result.</returns>
[Fact]
public void Apply_OutsideDevelopment_LeavesBothValidationsOff()
{
@@ -65,6 +69,7 @@ public class CentralServiceProviderValidationTests : IDisposable
private readonly CentralDbTestEnvironment _dbEnv;
private WebApplicationFactory<Program>? _factory;
/// <summary>Initializes a new instance of the <c>CentralServiceProviderValidationTests</c> class.</summary>
public CentralServiceProviderValidationTests()
{
_previousEnv = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT");
@@ -72,6 +77,7 @@ public class CentralServiceProviderValidationTests : IDisposable
_dbEnv = new CentralDbTestEnvironment();
}
/// <summary>Releases the resources held by this instance.</summary>
public void Dispose()
{
try
@@ -169,6 +175,7 @@ public class SiteServiceProviderValidationTests : IDisposable
private readonly string _tempAuditDbPath;
private WebApplication? _host;
/// <summary>Initializes a new instance of the <c>SiteServiceProviderValidationTests</c> class.</summary>
public SiteServiceProviderValidationTests()
{
_tempDbPath = Path.Combine(Path.GetTempPath(), $"sb_scopeval_{Guid.NewGuid()}.db");
@@ -177,6 +184,7 @@ public class SiteServiceProviderValidationTests : IDisposable
_tempAuditDbPath = Path.Combine(Path.GetTempPath(), $"sb_scopeval_audit_{Guid.NewGuid()}.db");
}
/// <summary>Releases the resources held by this instance.</summary>
public void Dispose()
{
(_host as IDisposable)?.Dispose();
@@ -186,6 +194,8 @@ public class SiteServiceProviderValidationTests : IDisposable
}
}
/// <summary>Verifies that the site composition root builds with scope validation enabled.</summary>
/// <returns>The result.</returns>
[Fact]
public void Site_CompositionRoot_BuildsWithScopeValidationEnabled()
{
@@ -16,6 +16,8 @@ namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
public class SessionActivityHttpIntegrationTests
{
/// <summary>Verifies that a passive ping and a role replacement do not renew the cookie, and the session expires when idle.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PassivePing_RoleReplacementDoesNotRenewCookie_AndIdleExpires()
{
@@ -31,6 +33,8 @@ public class SessionActivityHttpIntegrationTests
Assert.False((await fixture.Circuit!.GetAuthoritativeStateAsync()).User.Identity!.IsAuthenticated);
}
/// <summary>Verifies that antiforgery-protected activity renews both the HTTP session and the existing circuit, and then both expire.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AntiforgeryActivity_RenewsHttpAndExistingCircuit_ThenBothExpire()
{
@@ -53,6 +57,8 @@ public class SessionActivityHttpIntegrationTests
Assert.False((await fixture.Circuit.GetAuthoritativeStateAsync()).User.Identity!.IsAuthenticated);
}
/// <summary>Verifies that missing antiforgery does not renew the session, and expired activity cannot revive it.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task MissingAntiforgeryDoesNotRenew_AndExpiredActivityCannotRevive()
{
@@ -74,10 +80,14 @@ public class SessionActivityHttpIntegrationTests
private readonly WebApplication _app;
private HttpClient _client = null!;
private readonly Dictionary<string, string> _cookies = [];
/// <summary>Gets the fake clock that drives session expiry.</summary>
public Clock Clock { get; }
/// <summary>Gets the authentication state provider of the circuit created at login, if any.</summary>
public CookieAuthenticationStateProvider? Circuit { get; private set; }
private Fixture(WebApplication app, Clock clock) { _app = app; Clock = clock; }
/// <summary>Creates and starts a test server fixture.</summary>
/// <returns>The started fixture.</returns>
public static async Task<Fixture> CreateAsync()
{
var builder = WebApplication.CreateBuilder();
@@ -108,8 +118,17 @@ public class SessionActivityHttpIntegrationTests
fixture._client = app.GetTestClient();
return fixture;
}
/// <summary>Signs in through the test login endpoint.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task LoginAsync() { using var response = await SendAsync(HttpMethod.Get, "/test/login"); response.EnsureSuccessStatusCode(); }
/// <summary>Fetches an antiforgery request token from the test token endpoint.</summary>
/// <returns>The antiforgery request token.</returns>
public async Task<string> TokenAsync() { using var response = await SendAsync(HttpMethod.Get, "/test/token"); response.EnsureSuccessStatusCode(); return await response.Content.ReadAsStringAsync(); }
/// <summary>Sends a request carrying the stored cookies and an optional antiforgery token.</summary>
/// <param name="method">The HTTP method.</param>
/// <param name="path">The request path.</param>
/// <param name="token">The antiforgery token to send, or null for none.</param>
/// <returns>The HTTP response.</returns>
public async Task<HttpResponseMessage> SendAsync(HttpMethod method, string path, string? token = null)
{
using var request = new HttpRequestMessage(method, path);
@@ -120,16 +139,26 @@ public class SessionActivityHttpIntegrationTests
foreach (var value in values) { var pair = value.Split(';')[0].Split('=', 2); _cookies[pair[0]] = pair[1]; }
return response;
}
/// <summary>Disposes the HTTP client and the web application.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async ValueTask DisposeAsync() { _client.Dispose(); await _app.DisposeAsync(); }
}
private sealed class Clock : TimeProvider
{
/// <summary>Gets or sets the current fake time.</summary>
public DateTimeOffset Now { get; set; } = new(2026, 9, 9, 12, 0, 0, TimeSpan.Zero);
/// <inheritdoc />
public override DateTimeOffset GetUtcNow() => Now;
/// <summary>Advances the fake clock.</summary>
/// <param name="minutes">The number of minutes to advance.</param>
public void Advance(int minutes) => Now = Now.AddMinutes(minutes);
}
private sealed class Mapper : IGroupRoleMapper<string>
{
/// <summary>Maps every group set to the Deployer role.</summary>
/// <param name="groups">The directory groups to map (ignored).</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>A task yielding a fixed Deployer role mapping.</returns>
public Task<GroupRoleMapping<string>> MapAsync(IReadOnlyList<string> groups, CancellationToken ct)
=> Task.FromResult(new GroupRoleMapping<string>([Roles.Deployer], new RoleMappingResult([Roles.Deployer], ["1"], false)));
}
@@ -9,9 +9,12 @@ public class SingletonRegistrarTests
{
private sealed class EchoActor : ReceiveActor
{
/// <summary>Creates an actor that echoes every message back to its sender.</summary>
public EchoActor() => ReceiveAny(m => Sender.Tell(m));
}
/// <summary>Verifies that starting a singleton creates the manager and proxy with canonical names and a drain task.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Start_CreatesManagerAndProxy_WithCanonicalNames_AndDrainTask()
{
@@ -36,10 +39,12 @@ public class SingletonRegistrarTests
Assert.True(handle.Manager.IsNobody() || system.WhenTerminated.IsCompleted);
}
/// <summary>Verifies that starting a singleton with a role scope creates a role-scoped singleton that answers requests.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Start_WithRoleScope_CreatesRoleScopedSingleton_ThatAnswers()
{
// Round-2 N5: role scoping is what kept the two SITE singletons
// Role scoping is what kept the two SITE singletons
// (deployment-manager, event-log-handler) hand-rolled and drain-less.
using var system = CreateSingleNodeClusterSystem(); // roles = ["Central"]
var handle = SingletonRegistrar.Start(
@@ -35,7 +35,7 @@ public sealed class SiteCommandGrpcServiceTests : IDisposable
private readonly ActorSystem _system = ActorSystem.Create("sitecmd-tests");
/// <inheritdoc />
/// <summary>Disposes the actor system used by the tests.</summary>
public void Dispose() => _system.Dispose();
/// <summary>A dispatcher whose Deployment Manager proxy is a canned-reply responder.</summary>
@@ -88,12 +88,16 @@ public sealed class SiteCommandGrpcServiceTests : IDisposable
private sealed class FixedPskProvider(string key) : ISitePskProvider
{
/// <inheritdoc />
public ValueTask<string> GetAsync(string siteId, CancellationToken ct) => new(key);
/// <inheritdoc />
public void Invalidate(string siteId) { }
}
// ── Auth gating (delegated to ControlPlaneAuthInterceptor, proven wired here) ──
/// <summary>Verifies that a request without credentials is rejected with a permission-denied status.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task NoCredentials_IsRejected_WithPermissionDenied()
{
@@ -106,6 +110,8 @@ public sealed class SiteCommandGrpcServiceTests : IDisposable
Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode);
}
/// <summary>Verifies that a request with the wrong key is rejected with a permission-denied status.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task WrongKey_IsRejected_WithPermissionDenied()
{
@@ -120,6 +126,8 @@ public sealed class SiteCommandGrpcServiceTests : IDisposable
// ── Readiness ──
/// <summary>Verifies that a request made before the service is set ready is rejected as unavailable, even with a correct key.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task BeforeSetReady_IsRejected_WithUnavailable_EvenWithACorrectKey()
{
@@ -136,6 +144,8 @@ public sealed class SiteCommandGrpcServiceTests : IDisposable
// ── decode → dispatch → encode, one command per oneof group ──
/// <summary>Verifies that a lifecycle command round-trips through the dispatcher.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ExecuteLifecycle_RoundTripsThroughTheDispatcher()
{
@@ -151,6 +161,8 @@ public sealed class SiteCommandGrpcServiceTests : IDisposable
Assert.Equal("cmd-1", decoded.CommandId);
}
/// <summary>Verifies that an OPC UA command round-trips through the dispatcher.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ExecuteOpcUa_RoundTripsThroughTheDispatcher()
{
@@ -164,6 +176,8 @@ public sealed class SiteCommandGrpcServiceTests : IDisposable
Assert.IsType<BrowseNodeResult>(SiteCommandDtoMapper.FromOpcUaReply(reply));
}
/// <summary>Verifies that a debug snapshot query round-trips through the dispatcher.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ExecuteQuery_DebugSnapshot_RoundTripsThroughTheDispatcher()
{
@@ -178,6 +192,8 @@ public sealed class SiteCommandGrpcServiceTests : IDisposable
Assert.Equal("Site1.Pump1", decoded.InstanceUniqueName);
}
/// <summary>Verifies that an unsubscribe-debug-view query returns the fire-and-forget acknowledgement.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ExecuteQuery_UnsubscribeDebugView_ReturnsTheFireAndForgetAck()
{
@@ -195,6 +211,8 @@ public sealed class SiteCommandGrpcServiceTests : IDisposable
Assert.Equal(QueryReply.ReplyOneofCase.UnsubscribeDebugView, reply.ReplyCase);
}
/// <summary>Verifies that a parked-operation command round-trips through the node-local handler.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ExecuteParked_RoundTripsThroughTheNodeLocalHandler()
{
@@ -210,6 +228,8 @@ public sealed class SiteCommandGrpcServiceTests : IDisposable
Assert.Equal("corr-7", decoded.CorrelationId);
}
/// <summary>Verifies that a route command round-trips through the dispatcher.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ExecuteRoute_RoundTripsThroughTheDispatcher()
{
@@ -228,6 +248,8 @@ public sealed class SiteCommandGrpcServiceTests : IDisposable
// ── Failover: the reply completes before the leave is initiated ──
/// <summary>Verifies that a trigger-failover command returns the acknowledgement before the leave is initiated.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task TriggerFailover_ReturnsTheAck_BeforeTheLeaveIsInitiated()
{
@@ -267,6 +289,7 @@ public sealed class SiteCommandGrpcServiceTests : IDisposable
/// <summary>Deployment Manager stand-in: replies each command with a canned reply of the right group.</summary>
private sealed class Responder : ReceiveActor
{
/// <summary>Initializes the responder so it replies to every received command with a canned reply.</summary>
public Responder() => ReceiveAny(msg => Sender.Tell(ReplyFor(msg)));
private static object ReplyFor(object m) => m switch
@@ -24,7 +24,7 @@ namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
/// <item><description><c>SiteRuntime/Actors/DeploymentManagerActor.cs</c> — per-call
/// resolve.</description></item>
/// <item><description><c>SiteRuntime/Scripts/ScriptRunLauncher.cs</c> — per-run resolve.
/// Not among the five the register row named: it is the run body WP3.1 lifted verbatim
/// Not among the five the register row named: it is the run body that was lifted verbatim
/// out of the deleted ScriptExecutionActor/AlarmExecutionActor, so it inherited the
/// pattern rather than adding it.</description></item>
/// </list>
@@ -114,6 +114,7 @@ public class SiteEventLoggerLifetimePinTests
return services;
}
/// <summary>Verifies that <c>ISiteEventLogger</c> is registered as a singleton in the site service graph.</summary>
[Fact]
public void ISiteEventLogger_IsRegisteredSingleton_InTheSiteServiceGraph()
{
@@ -125,6 +126,7 @@ public class SiteEventLoggerLifetimePinTests
$"{Why}\nActual lifetime: {descriptor.Lifetime}.");
}
/// <summary>Verifies that the concrete type the interface forwards to is also registered as a singleton.</summary>
[Fact]
public void SiteEventLogger_TheConcreteTypeTheInterfaceForwardsTo_IsAlsoSingleton()
{
@@ -34,6 +34,7 @@ public class SiteHealthCheckTests : IDisposable
private readonly string _tempSiteDbPath;
private readonly string _tempTrackingDbPath;
/// <summary>Initializes the test host with a temporary site database and the site health checks.</summary>
public SiteHealthCheckTests()
{
var stamp = Guid.NewGuid();
@@ -68,6 +69,7 @@ public class SiteHealthCheckTests : IDisposable
_host = builder.Build();
}
/// <summary>Releases the resources held by this instance.</summary>
public void Dispose()
{
(_host as IDisposable)?.Dispose();
@@ -82,6 +84,7 @@ public class SiteHealthCheckTests : IDisposable
private IReadOnlyList<HealthCheckRegistration> Registrations =>
_host.Services.GetRequiredService<IOptions<HealthCheckServiceOptions>>().Value.Registrations.ToList();
/// <summary>Verifies that a site registers exactly the three site health checks.</summary>
[Fact]
public void Site_RegistersExactlyTheThreeSiteChecks()
{
@@ -93,6 +96,9 @@ public class SiteHealthCheckTests : IDisposable
Registrations.Select(r => r.Name).OrderBy(n => n, StringComparer.Ordinal).ToArray());
}
/// <summary>Verifies that each site check carries its intended tier tag.</summary>
/// <param name="name">The health check registration name.</param>
/// <param name="expectedTag">The tier tag the check is expected to carry.</param>
[Theory]
[InlineData("akka-cluster", ZbHealthTags.Ready)]
[InlineData("localdb", ZbHealthTags.Ready)]
@@ -106,6 +112,9 @@ public class SiteHealthCheckTests : IDisposable
Assert.Equal(new[] { expectedTag }, registration.Tags.ToArray());
}
/// <summary>Verifies that each site check resolves to its intended implementation type.</summary>
/// <param name="name">The health check registration name.</param>
/// <param name="expected">The expected check implementation type.</param>
[Theory]
[InlineData("akka-cluster", typeof(AkkaClusterHealthCheck))]
[InlineData("localdb", typeof(SiteLocalDbHealthCheck))]
@@ -121,6 +130,7 @@ public class SiteHealthCheckTests : IDisposable
Assert.IsType(expected, check);
}
/// <summary>Verifies that the site active-node check is role-scoped and is not the unscoped central check.</summary>
[Fact]
public void Site_ActiveNodeCheck_IsRoleScoped_NotTheUnscopedCentralCheck()
{
@@ -136,6 +146,8 @@ public class SiteHealthCheckTests : IDisposable
Assert.IsNotType<ActiveNodeHealthCheck>(check);
}
/// <summary>Verifies that a site does not register central-only health checks.</summary>
/// <param name="centralOnlyName">The name of a check that only central should register.</param>
[Theory]
[InlineData("database")]
[InlineData("required-singletons")]
@@ -148,6 +160,8 @@ public class SiteHealthCheckTests : IDisposable
Assert.DoesNotContain(Registrations, r => r.Name == centralOnlyName);
}
/// <summary>Verifies that the LocalDb check reports healthy against the real site database.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Site_LocalDbCheck_ReportsHealthyAgainstTheRealSiteDatabase()
{
@@ -161,6 +175,8 @@ public class SiteHealthCheckTests : IDisposable
Assert.Equal(HealthStatus.Healthy, result.Status);
}
/// <summary>Verifies that the LocalDb check adds replication state without failing when replication is off by default.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Site_LocalDbCheck_EnrichesWithReplicationStateWithoutFailingOnDefaultOff()
{
@@ -176,6 +192,8 @@ public class SiteHealthCheckTests : IDisposable
Assert.Equal(0L, Assert.IsType<long>(result.Data["replicationOplogBacklog"]));
}
/// <summary>Verifies that the LocalDb check reports unhealthy when the store is unreachable.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Site_LocalDbCheck_ReportsUnhealthyWhenTheStoreIsUnreachable()
{
@@ -186,6 +204,10 @@ public class SiteHealthCheckTests : IDisposable
Assert.Equal(HealthStatus.Unhealthy, result.Status);
}
/// <summary>Verifies that the active-node check separates the primary from the standby.</summary>
/// <param name="selfIsPrimary">Whether this node is the primary.</param>
/// <param name="expected">The health status the check is expected to report.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory]
[InlineData(true, HealthStatus.Healthy)]
[InlineData(false, HealthStatus.Unhealthy)]
@@ -207,16 +229,30 @@ public class SiteHealthCheckTests : IDisposable
private sealed class StubNodeProvider(bool selfIsPrimary) : IClusterNodeProvider
{
/// <inheritdoc />
public bool SelfIsPrimary { get; } = selfIsPrimary;
/// <inheritdoc />
public IReadOnlyList<NodeStatus> GetClusterNodes() => [];
}
private sealed class ThrowingLocalDb : ILocalDb
{
/// <summary>Always throws to simulate an unreachable store.</summary>
/// <param name="sql">The SQL text (ignored).</param>
/// <param name="parameters">The statement parameters (ignored).</param>
/// <param name="ct">A token to cancel the operation (ignored).</param>
/// <returns>Never returns; always throws <see cref="InvalidOperationException"/>.</returns>
public Task<int> ExecuteAsync(string sql, object? parameters = null, CancellationToken ct = default) =>
throw new InvalidOperationException("store unreachable");
/// <summary>Always throws to simulate an unreachable store.</summary>
/// <typeparam name="T">The row type.</typeparam>
/// <param name="sql">The SQL text (ignored).</param>
/// <param name="map">The row mapper (ignored).</param>
/// <param name="parameters">The query parameters (ignored).</param>
/// <param name="ct">A token to cancel the operation (ignored).</param>
/// <returns>Never returns; always throws <see cref="InvalidOperationException"/>.</returns>
public Task<IReadOnlyList<T>> QueryAsync<T>(
string sql,
Func<Microsoft.Data.Sqlite.SqliteDataReader, T> map,
@@ -224,21 +260,37 @@ public class SiteHealthCheckTests : IDisposable
CancellationToken ct = default) =>
throw new InvalidOperationException("store unreachable");
/// <summary>Always throws to simulate an unreachable store.</summary>
/// <param name="ct">A token to cancel the operation (ignored).</param>
/// <returns>Never returns; always throws <see cref="InvalidOperationException"/>.</returns>
public Task<ILocalDbTransaction> BeginTransactionAsync(CancellationToken ct = default) =>
throw new InvalidOperationException("store unreachable");
/// <summary>Always throws to simulate an unreachable store.</summary>
/// <returns>Never returns; always throws <see cref="InvalidOperationException"/>.</returns>
public Microsoft.Data.Sqlite.SqliteConnection CreateConnection() =>
throw new InvalidOperationException("store unreachable");
/// <summary>Always throws to simulate an unreachable store.</summary>
/// <param name="tableName">The table name (ignored).</param>
/// <returns>Never returns; always throws <see cref="InvalidOperationException"/>.</returns>
public ReplicatedTable RegisterReplicated(string tableName) =>
throw new InvalidOperationException("store unreachable");
/// <summary>Always throws to simulate an unreachable store.</summary>
/// <param name="tableName">The table name (ignored).</param>
/// <param name="baselineExistingRows">Whether to baseline existing rows (ignored).</param>
/// <returns>Never returns; always throws <see cref="InvalidOperationException"/>.</returns>
public ReplicatedTable RegisterReplicated(string tableName, bool baselineExistingRows) =>
throw new InvalidOperationException("store unreachable");
/// <summary>Always throws to simulate an unreachable store.</summary>
/// <param name="tableName">The table name (ignored).</param>
/// <returns>Never returns; always throws <see cref="InvalidOperationException"/>.</returns>
public bool DeregisterReplicated(string tableName) =>
throw new InvalidOperationException("store unreachable");
/// <summary>Gets the replicated tables; always throws to simulate an unreachable store.</summary>
public IReadOnlyDictionary<string, ReplicatedTable> ReplicatedTables =>
throw new InvalidOperationException("store unreachable");
}
@@ -29,13 +29,14 @@ public class SiteHealthEndpointTests : IDisposable
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-site-1}
// 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
@@ -52,7 +53,7 @@ public class SiteHealthEndpointTests : IDisposable
SetEnv("ScadaBridge__Cluster__SeedNodes__1", "akka.tcp://scadabridge@localhost:18085");
SetEnv("ScadaBridge__Communication__GrpcPsk", "test-psk-0123456789");
SetEnv("LocalDb__Path", _tempDbPath);
// arch-review remediation WP1.2: SqliteAuditWriterOptions.DatabasePath has no default
// 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).
@@ -65,6 +66,7 @@ public class SiteHealthEndpointTests : IDisposable
Environment.SetEnvironmentVariable(key, value);
}
/// <summary>Releases the resources held by this test class.</summary>
public void Dispose()
{
foreach (var d in _disposables)
@@ -109,6 +111,8 @@ public class SiteHealthEndpointTests : IDisposable
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()
{
@@ -130,6 +134,8 @@ public class SiteHealthEndpointTests : IDisposable
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()
{
@@ -145,6 +151,8 @@ public class SiteHealthEndpointTests : IDisposable
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()
{
@@ -10,10 +10,11 @@ namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
/// </summary>
public class SiteKestrelSetupTests
{
/// <summary>Verifies that the site listener enables HTTP/2 keepalive pings.</summary>
[Fact]
public void SiteListener_EnablesHttp2Keepalive()
{
// Arch-review 2026-08-19 P4-3. Kestrel's default KeepAlivePingDelay is TimeSpan.MaxValue —
// Kestrel's default KeepAlivePingDelay is TimeSpan.MaxValue —
// no server-initiated pings — so a half-open HTTP/2 connection is never detected on a
// stream that is legitimately silent. The LocalDb sync stream is exactly that: with the
// dialling node crashed or partitioned, this node's handler stays parked on a dead stream
@@ -32,6 +33,7 @@ public class SiteKestrelSetupTests
<= TimeSpan.FromMinutes(2));
}
/// <summary>Verifies that the site listener keeps the gRPC port HTTP/2-only and the metrics port HTTP/1.1-capable.</summary>
[Fact]
public void SiteListener_KeepsGrpcHttp2Only_AndMetricsHttp1Capable()
{
@@ -6,7 +6,7 @@ using ZB.MOM.WW.LocalDb;
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
/// <summary>
/// Arch-review 2026-08-19 P4-6 — <c>PRAGMA auto_vacuum = INCREMENTAL</c> only takes effect on
/// <c>PRAGMA auto_vacuum = INCREMENTAL</c> only takes effect on
/// a table-less database, and <c>SqliteLocalDb</c>'s constructor creates LocalDb's own
/// bookkeeping tables before anything else ever gets a connection to the consolidated site
/// file. <see cref="SiteLocalDbSetup.EnsureAutoVacuumOnFreshFile"/> is the fix: it pre-creates
@@ -23,6 +23,7 @@ namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
/// </remarks>
public class SiteLocalDbAutoVacuumTests
{
/// <summary>Verifies that a fresh file reports incremental auto-vacuum.</summary>
[Fact]
public void FreshFile_ReportsIncrementalAutoVacuum()
{
@@ -43,6 +44,7 @@ public class SiteLocalDbAutoVacuumTests
}
}
/// <summary>Verifies that incremental auto-vacuum survives LocalDb creating its own bookkeeping tables afterward.</summary>
[Fact]
public void FreshFile_AutoVacuumSurvives_LocalDbCreatingItsOwnBookkeepingTablesAfterward()
{
@@ -82,6 +84,7 @@ public class SiteLocalDbAutoVacuumTests
}
}
/// <summary>Verifies that an existing file is left alone and its auto-vacuum stays none.</summary>
[Fact]
public void ExistingFile_IsLeftAlone_AutoVacuumStaysNone()
{
@@ -118,6 +121,7 @@ public class SiteLocalDbAutoVacuumTests
}
}
/// <summary>Verifies that a missing LocalDb path does not throw.</summary>
[Fact]
public void MissingLocalDbPath_DoesNotThrow()
{
@@ -5,7 +5,7 @@ using ZB.MOM.WW.LocalDb;
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
/// <summary>
/// WP1.3 — CDC capture is installed only on a node that actually replicates.
/// CDC capture is installed only on a node that actually replicates.
/// </summary>
/// <remarks>
/// <para>
@@ -27,12 +27,14 @@ public class SiteLocalDbCdcRegistrationTests : IDisposable
private readonly string _root;
private readonly List<ServiceProvider> _providers = [];
/// <summary>Initializes the test with a fresh temporary directory for the database files.</summary>
public SiteLocalDbCdcRegistrationTests()
{
_root = Path.Combine(Path.GetTempPath(), $"localdb-cdc-{Guid.NewGuid():N}");
Directory.CreateDirectory(_root);
}
/// <summary>Disposes every created provider and clears the SQLite connection pools.</summary>
public void Dispose()
{
foreach (var provider in _providers)
@@ -45,6 +47,7 @@ public class SiteLocalDbCdcRegistrationTests : IDisposable
GC.SuppressFinalize(this);
}
/// <summary>Verifies that an unreplicated node installs no capture triggers.</summary>
[Fact]
public void UnreplicatedNode_InstallsNoCaptureTriggers()
{
@@ -54,6 +57,7 @@ public class SiteLocalDbCdcRegistrationTests : IDisposable
Assert.Empty(CaptureTriggers(db));
}
/// <summary>Verifies that an unreplicated node still creates every table.</summary>
[Fact]
public void UnreplicatedNode_StillCreatesEveryTable()
{
@@ -68,6 +72,7 @@ public class SiteLocalDbCdcRegistrationTests : IDisposable
Assert.Contains(table, tables);
}
/// <summary>Verifies that an initiator node installs capture triggers.</summary>
[Fact]
public void InitiatorNode_InstallsCaptureTriggers()
{
@@ -78,6 +83,7 @@ public class SiteLocalDbCdcRegistrationTests : IDisposable
AssertCaptureTriggersCoverTheReplicatedTables(db);
}
/// <summary>Verifies that a passive node with an API key but no peer address still installs capture triggers.</summary>
[Fact]
public void PassiveNode_WithApiKeyButNoPeerAddress_StillInstallsCaptureTriggers()
{
@@ -90,6 +96,7 @@ public class SiteLocalDbCdcRegistrationTests : IDisposable
AssertCaptureTriggersCoverTheReplicatedTables(db);
}
/// <summary>Verifies that a replicated node installs no capture triggers on the central-only notification tables.</summary>
[Fact]
public void ReplicatedNode_InstallsNoCaptureTriggersOnTheCentralOnlyNotificationTables()
{
@@ -104,6 +111,7 @@ public class SiteLocalDbCdcRegistrationTests : IDisposable
Assert.DoesNotContain(triggers, t => t.StartsWith("__localdb_smtp_configurations_", StringComparison.Ordinal));
}
/// <summary>Verifies that a replicated node installs no capture triggers on the node-local mirrored secrets ledger.</summary>
[Fact]
public void ReplicatedNode_InstallsNoCaptureTriggersOnTheNodeLocalMirroredSecretsLedger()
{
@@ -119,10 +127,11 @@ public class SiteLocalDbCdcRegistrationTests : IDisposable
Assert.DoesNotContain("mirrored_secrets", db.ReplicatedTables.Keys);
}
/// <summary>Verifies that an unreplicated node drops triggers left behind by a previously replicated build.</summary>
[Fact]
public void UnreplicatedNode_DropsTriggersLeftByAPreviouslyReplicatedBuild()
{
// WP3.3, the residual WP1.3 could only document: skipping registration never removed
// The residual that skipping registration alone could not close: skipping registration never removed
// what an EARLIER build installed, so a long-lived unreplicated node upgraded in place
// kept capturing forever. LocalDb 0.2.0's DeregisterReplicated closes it, and this is
// the exact upgrade shape — the same file, booted first WITH replication configured and
@@ -137,6 +146,7 @@ public class SiteLocalDbCdcRegistrationTests : IDisposable
Assert.Empty(CaptureTriggers(unreplicated));
}
/// <summary>Verifies that an unreplicated node prunes the oplog left behind by a previously replicated build.</summary>
[Fact]
public void UnreplicatedNode_PrunesTheOplogLeftByAPreviouslyReplicatedBuild()
{
@@ -156,6 +166,7 @@ public class SiteLocalDbCdcRegistrationTests : IDisposable
Assert.Equal(1, SiteEventCount(unreplicated));
}
/// <summary>Verifies that an unreplicated node over a file that was never registered is a no-op.</summary>
[Fact]
public void UnreplicatedNode_OverAFileThatWasNeverRegistered_IsANoOp()
{
@@ -172,6 +183,7 @@ public class SiteLocalDbCdcRegistrationTests : IDisposable
Assert.Contains(table, TableNames(second));
}
/// <summary>Verifies that a replicated node baselines rows that predate registration.</summary>
[Fact]
public void ReplicatedNode_BaselinesRowsThatPredateRegistration()
{
@@ -194,6 +206,7 @@ public class SiteLocalDbCdcRegistrationTests : IDisposable
Assert.Equal(0L, RowVersionHlc(replicated, "site_events"));
}
/// <summary>Verifies that an unreplicated node still runs the legacy migrator.</summary>
[Fact]
public void UnreplicatedNode_StillRunsTheLegacyMigrator()
{
@@ -29,6 +29,7 @@ namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
/// </remarks>
public class SiteLocalDbDirectoryTests
{
/// <summary>Verifies that site registration creates the LocalDb directory when it does not exist.</summary>
[Fact]
public void SiteRegistration_CreatesTheLocalDbDirectory_WhenItDoesNotExist()
{
@@ -7,7 +7,7 @@ using ZB.MOM.WW.ScadaBridge.Host;
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
/// <summary>
/// LocalDb Phase 1 (Task 6) — the one-time copy of the pre-Phase-1 site databases into
/// LocalDb: the one-time copy of the pre-LocalDb site databases into
/// the consolidated one.
/// </summary>
/// <remarks>
@@ -21,12 +21,14 @@ public class SiteLocalDbLegacyMigratorTests : IDisposable
private readonly string _root;
private readonly List<ServiceProvider> _providers = [];
/// <summary>Initializes a new instance of the <c>SiteLocalDbLegacyMigratorTests</c> class.</summary>
public SiteLocalDbLegacyMigratorTests()
{
_root = Path.Combine(Path.GetTempPath(), $"localdb-migrator-{Guid.NewGuid():N}");
Directory.CreateDirectory(_root);
}
/// <summary>Releases the resources held by this test class.</summary>
public void Dispose()
{
foreach (var provider in _providers)
@@ -43,9 +45,9 @@ public class SiteLocalDbLegacyMigratorTests : IDisposable
/// <summary>A consolidated database with the tables created and registered, as the host has it.</summary>
/// <remarks>
/// <c>sf_messages</c> is registered here even though production <c>OnReady</c> does not
/// register it until the Task 14 cutover. The oplog assertion below needs live capture
/// register it until the store-and-forward cutover. The oplog assertion below needs live capture
/// triggers to mean anything, and the property under test — that the migrator runs after
/// registration — is the same either way. The corollary is a real constraint on Task 14:
/// registration — is the same either way. The corollary is a real constraint on that cutover:
/// <c>Migrate</c> must stay the LAST call in <c>OnReady</c>, after every registration.
/// </remarks>
private ILocalDb CreateConsolidated(IConfiguration config)
@@ -195,6 +197,7 @@ public class SiteLocalDbLegacyMigratorTests : IDisposable
return ids;
}
/// <summary>Verifies that missing legacy files are a no op.</summary>
[Fact]
public void MissingLegacyFiles_AreANoOp()
{
@@ -210,6 +213,7 @@ public class SiteLocalDbLegacyMigratorTests : IDisposable
Assert.Equal(0, CountRows(db, "site_events"));
}
/// <summary>Verifies that legacy tracking rows are copied and the file is renamed.</summary>
[Fact]
public void LegacyTrackingRows_AreCopiedAndTheFileIsRenamed()
{
@@ -228,6 +232,7 @@ public class SiteLocalDbLegacyMigratorTests : IDisposable
Assert.True(File.Exists(trackingPath + ".migrated"));
}
/// <summary>Verifies that legacy reads disable pooling or the rename fails on windows.</summary>
[Fact]
public void LegacyReadsDisablePooling_OrTheRenameFailsOnWindows()
{
@@ -251,6 +256,7 @@ public class SiteLocalDbLegacyMigratorTests : IDisposable
Assert.Contains("Mode=ReadOnly", cs, StringComparison.OrdinalIgnoreCase);
}
/// <summary>Verifies that legacy events get deterministic ids not fresh guids.</summary>
[Fact]
public void LegacyEvents_GetDeterministicIds_NotFreshGuids()
{
@@ -268,6 +274,7 @@ public class SiteLocalDbLegacyMigratorTests : IDisposable
SelectIds(db, "site_events", "id"));
}
/// <summary>Verifies that second boot is a no op because the file is already marked migrated.</summary>
[Fact]
public void SecondBoot_IsANoOp_BecauseTheFileIsAlreadyMarkedMigrated()
{
@@ -285,6 +292,7 @@ public class SiteLocalDbLegacyMigratorTests : IDisposable
Assert.Equal(2, CountRows(db, "site_events"));
}
/// <summary>Verifies that rerun against an unrenamed file does not duplicate.</summary>
[Fact]
public void RerunAgainstAnUnrenamedFile_DoesNotDuplicate()
{
@@ -311,6 +319,7 @@ public class SiteLocalDbLegacyMigratorTests : IDisposable
Assert.Equal(2, CountRows(db, "site_events"));
}
/// <summary>Verifies that migrated rows enter the oplog so they actually replicate.</summary>
[Fact]
public void MigratedRows_EnterTheOplog_SoTheyActuallyReplicate()
{
@@ -332,10 +341,11 @@ public class SiteLocalDbLegacyMigratorTests : IDisposable
Assert.Equal(2L, (long)cmd.ExecuteScalar()!);
}
/// <summary>Verifies that sf messages are copied from the legacy file.</summary>
[Fact]
public void SfMessages_AreCopiedFromTheLegacyFile()
{
// Phase 2 (Task 8). Unlike the two Phase 1 files, the store-and-forward default path
// Unlike the two earlier site database files, the store-and-forward default path
// is inside the data volume, so on a real deployment this actually moves data:
// undelivered messages that a lost migration would silently discard.
var sfPath = Path_("store-and-forward.db");
@@ -353,6 +363,7 @@ public class SiteLocalDbLegacyMigratorTests : IDisposable
Assert.True(File.Exists(sfPath + ".migrated"));
}
/// <summary>Verifies that sf messages migration is idempotent when rerun after a crash before rename.</summary>
[Fact]
public void SfMessages_Migration_IsIdempotent_WhenRerunAfterACrashBeforeRename()
{
@@ -370,6 +381,7 @@ public class SiteLocalDbLegacyMigratorTests : IDisposable
Assert.Equal(2, CountRows(db, "sf_messages"));
}
/// <summary>Verifies that migrated sf messages enter the oplog so they actually replicate.</summary>
[Fact]
public void MigratedSfMessages_EnterTheOplog_SoTheyActuallyReplicate()
{
@@ -389,6 +401,7 @@ public class SiteLocalDbLegacyMigratorTests : IDisposable
Assert.Equal(2L, (long)cmd.ExecuteScalar()!);
}
/// <summary>Verifies that sf messages from an older build migrate despite missing columns.</summary>
[Fact]
public void SfMessages_FromAnOlderBuild_MigrateDespiteMissingColumns()
{
@@ -463,10 +476,11 @@ public class SiteLocalDbLegacyMigratorTests : IDisposable
cmd.ExecuteNonQuery();
}
/// <summary>Verifies that site config tables are copied from the legacy file.</summary>
[Fact]
public void SiteConfigTables_AreCopiedFromTheLegacyFile()
{
// Phase 2 (Task 9). All seven migrated tables live in one legacy file and are copied
// All seven migrated tables live in one legacy file and are copied
// in a single transaction: a partial config migration would leave a site node running
// against half its old configuration, which is worse than failing startup.
var sitePath = Path_("scadabridge.db");
@@ -483,6 +497,7 @@ public class SiteLocalDbLegacyMigratorTests : IDisposable
Assert.True(File.Exists(sitePath + ".migrated"));
}
/// <summary>Verifies that migration does not copy notification or smtp rows even when the legacy file has them.</summary>
[Fact]
public void Migration_DoesNotCopyNotificationOrSmtpRows_EvenWhenTheLegacyFileHasThem()
{
@@ -510,6 +525,7 @@ public class SiteLocalDbLegacyMigratorTests : IDisposable
Assert.Equal(0L, (long)cmd.ExecuteScalar()!);
}
/// <summary>Verifies that migrated config rows enter the oplog so they actually replicate.</summary>
[Fact]
public void MigratedConfigRows_EnterTheOplog_SoTheyActuallyReplicate()
{
@@ -527,6 +543,7 @@ public class SiteLocalDbLegacyMigratorTests : IDisposable
Assert.Equal(1L, (long)cmd.ExecuteScalar()!);
}
/// <summary>Verifies that site config migration is idempotent when rerun after a crash before rename.</summary>
[Fact]
public void SiteConfigMigration_IsIdempotent_WhenRerunAfterACrashBeforeRename()
{
@@ -543,6 +560,7 @@ public class SiteLocalDbLegacyMigratorTests : IDisposable
Assert.Equal(1, CountRows(db, table.Table));
}
/// <summary>Verifies that migrator column lists match the live schema.</summary>
[Fact]
public void MigratorColumnLists_MatchTheLiveSchema()
{
@@ -571,6 +589,7 @@ public class SiteLocalDbLegacyMigratorTests : IDisposable
}
}
/// <summary>Verifies that legacy file without the expected table is treated as empty not a failure.</summary>
[Fact]
public void LegacyFileWithoutTheExpectedTable_IsTreatedAsEmpty_NotAFailure()
{
@@ -593,6 +612,7 @@ public class SiteLocalDbLegacyMigratorTests : IDisposable
Assert.True(File.Exists(trackingPath + ".migrated"));
}
/// <summary>Verifies that in memory legacy connection strings are skipped.</summary>
[Fact]
public void InMemoryLegacyConnectionStrings_AreSkipped()
{
@@ -616,6 +636,7 @@ public class SiteLocalDbLegacyMigratorTests : IDisposable
Assert.Equal(0, CountRows(db, "site_events"));
}
/// <summary>Verifies that unset legacy keys fall back to the cwd relative code defaults.</summary>
[Fact]
public void UnsetLegacyKeys_FallBackToTheCwdRelativeCodeDefaults()
{
@@ -6,7 +6,7 @@ using ZB.MOM.WW.LocalDb;
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
/// <summary>
/// Deferred-work register row 105 (arch-review 2026-08-19 P4-7 → LocalDb 0.3.3): a replicated
/// Guards the LocalDb 0.3.3 restriction: a replicated
/// table must never be the parent of a foreign key with a row-mutating <c>ON DELETE</c> action
/// (<c>CASCADE</c> / <c>SET NULL</c> / <c>SET DEFAULT</c>). LocalDb's LWW applier upserts with
/// <c>INSERT OR REPLACE</c> under <c>foreign_keys=ON</c>, so the implicit delete would fire the
@@ -16,8 +16,8 @@ namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
/// pair. This test moves that discovery from the first replicated boot to the build: it walks
/// the real site schema (every table <c>SiteLocalDbSetup.OnReady</c> creates) and asserts none
/// of the twelve <see cref="SiteLocalDbSetup.ReplicatedTables"/> (incl. <c>deployed_secrets</c>,
/// added for site-secrets artifact deploy, and <c>secret_mirror_status</c>, added by review
/// finding M1) has such an inbound FK.
/// added for site-secrets artifact deploy, and <c>secret_mirror_status</c>, added by a
/// review finding) has such an inbound FK.
/// </summary>
/// <remarks>
/// The schema is built with an UNREPLICATED config on purpose: a replicated one would make
@@ -27,6 +27,7 @@ namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
/// </remarks>
public class SiteLocalDbReplicatedTableFkGuardTests
{
/// <summary>Verifies that no replicated table is the parent of a foreign key with a row-mutating <c>ON DELETE</c> action.</summary>
[Fact]
public void NoReplicatedTable_IsAParentOfARowMutatingOnDeleteForeignKey()
{
@@ -64,6 +65,7 @@ public class SiteLocalDbReplicatedTableFkGuardTests
}
}
/// <summary>Verifies, as a negative control, that the checker detects a child table with a cascading foreign key.</summary>
[Fact]
public void NegativeControl_CheckerFindsACascadingChild()
{
@@ -14,7 +14,7 @@ using ZB.MOM.WW.ScadaBridge.Host.Actors;
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
/// <summary>
/// LocalDb Phase 1 (Task 3) — locks the consolidated site database into the REAL site
/// LocalDb Phase 1 — locks the consolidated site database into the REAL site
/// composition root.
/// <para>
/// These tests build the actual container from
@@ -34,6 +34,7 @@ public class SiteLocalDbWiringTests : IDisposable
private readonly string _tempSiteDbPath;
private readonly string _tempTrackingDbPath;
/// <summary>Builds the site host over a temporary LocalDb file.</summary>
public SiteLocalDbWiringTests()
{
var stamp = Guid.NewGuid();
@@ -78,6 +79,7 @@ public class SiteLocalDbWiringTests : IDisposable
_host = builder.Build();
}
/// <summary>Releases the resources held by this test class.</summary>
public void Dispose()
{
(_host as IDisposable)?.Dispose();
@@ -89,12 +91,14 @@ public class SiteLocalDbWiringTests : IDisposable
GC.SuppressFinalize(this);
}
/// <summary>Verifies that the site composition root resolves the LocalDb service.</summary>
[Fact]
public void Site_Resolves_ILocalDb()
{
Assert.NotNull(_host.Services.GetService<ILocalDb>());
}
/// <summary>Verifies that the site LocalDb registers both Phase 1 replicated tables.</summary>
[Fact]
public void Site_LocalDb_RegistersBothReplicatedTables()
{
@@ -108,6 +112,7 @@ public class SiteLocalDbWiringTests : IDisposable
Assert.Contains("site_events", db.ReplicatedTables.Keys);
}
/// <summary>Verifies that the replicated tables declare explicit primary keys.</summary>
[Fact]
public void Site_LocalDb_ReplicatedTablesHaveExplicitPrimaryKeys()
{
@@ -121,6 +126,7 @@ public class SiteLocalDbWiringTests : IDisposable
Assert.Equal(["id"], db.ReplicatedTables["site_events"].PkColumns);
}
/// <summary>Verifies that the site LocalDb service is registered as a singleton.</summary>
[Fact]
public void Site_LocalDb_IsASingleton()
{
@@ -133,10 +139,11 @@ public class SiteLocalDbWiringTests : IDisposable
Assert.Same(first, second);
}
/// <summary>Verifies that replication is registered but inert when no peer is configured.</summary>
[Fact]
public void Site_Replication_IsRegistered_AndInertWithNoPeer()
{
// Task 7's default-OFF pin. This fixture configures NO
// The default-OFF pin. This fixture configures NO
// LocalDb:Replication:PeerAddress, which is the shipping default, so the
// engine must resolve cleanly and sit idle rather than throw, retry, or
// report a connection. "Default-off" here means inert, NOT unregistered —
@@ -149,6 +156,7 @@ public class SiteLocalDbWiringTests : IDisposable
Assert.Null(status.LastSyncUtc);
}
/// <summary>Verifies that a healthy idle node reports an oplog backlog of zero rather than null.</summary>
[Fact]
public void Site_Replication_OplogBacklog_IsZero_NotNull_OnAHealthyIdleNode()
{
@@ -156,22 +164,24 @@ public class SiteLocalDbWiringTests : IDisposable
// wired, or the poll failed) and must never be reported as a healthy 0. On a
// node whose local database is present and readable, a real 0 proves the
// backlog provider is actually wired to the oplog — a null here would mean
// Task 9's health signal is about to publish "unknown" forever.
// The health signal is about to publish "unknown" forever.
var status = _host.Services.GetRequiredService<ISyncStatus>();
Assert.Equal(0L, status.OplogBacklog);
}
/// <summary>Verifies that the replication health bridge is registered as a hosted service.</summary>
[Fact]
public void Site_Replication_HealthBridge_IsRegisteredAsAHostedService()
{
// Task 9. Registered via a factory lambda, so ImplementationType is null and the
// Registered via a factory lambda, so ImplementationType is null and the
// only honest assertion is on the resolved instance.
var hosted = _host.Services.GetServices<IHostedService>();
Assert.Contains(hosted, h => h is LocalDbReplicationStatusReporter);
}
/// <summary>Verifies that the replication health bridge publishes its status onto the health report.</summary>
[Fact]
public void Site_Replication_HealthBridge_PublishesStatusOntoTheHealthReport()
{
@@ -202,6 +212,7 @@ public class SiteLocalDbWiringTests : IDisposable
Assert.Equal(0L, report.LocalDbOplogBacklog);
}
/// <summary>Verifies that the LocalDb health report fields are null before the reporter has run.</summary>
[Fact]
public void Site_HealthReport_LocalDbFields_AreNull_BeforeTheReporterHasRun()
{
@@ -216,6 +227,7 @@ public class SiteLocalDbWiringTests : IDisposable
Assert.Null(report.LocalDbOplogBacklog);
}
/// <summary>Verifies that site telemetry allowlists the LocalDb replication meter.</summary>
[Fact]
public void Site_Telemetry_Allowlists_TheLocalDbReplicationMeter()
{
@@ -228,15 +240,16 @@ public class SiteLocalDbWiringTests : IDisposable
// This asserts on the allowlist constant, not on OTel's observed-meter set:
// AddZbTelemetry applies its options to a local instance and never registers
// IOptions, so there is nothing resolvable to interrogate. The end-to-end proof
// that localdb_* actually reaches /metrics is the Task 12 live gate; this test
// that localdb_* actually reaches /metrics is the live gate; this test
// exists to stop the entry being dropped again.
Assert.Contains(LocalDbMetrics.MeterName, SiteServiceRegistration.ObservedMeters);
}
/// <summary>Verifies that the Phase 2 tables are created in the consolidated LocalDb file.</summary>
[Fact]
public void Site_LocalDb_CreatesThePhase2Tables_InTheConsolidatedFile()
{
// Phase 2 (Task 7). The nine configuration tables and the store-and-forward buffer
// Phase 2. The nine configuration tables and the store-and-forward buffer
// now live in the consolidated file rather than in scadabridge.db and
// store-and-forward.db. Asserting through the REAL composition root is what proves
// OnReady applies both schemas — the stores themselves also call Apply, so a test
@@ -261,11 +274,12 @@ public class SiteLocalDbWiringTests : IDisposable
}
}
/// <summary>Verifies that the site LocalDb registers exactly the twelve expected replicated tables.</summary>
[Fact]
public void Site_LocalDb_RegistersExactlyTheTwelveReplicatedTables()
{
// The Phase 2 cutover, extended by site-secrets artifact deploy's deployed_secrets
// and review finding M1's per-node secret_mirror_status.
// and the per-node secret_mirror_status.
// This is an EXACTLY check rather than a Contains in both directions, and both
// directions are load-bearing.
//
@@ -292,6 +306,7 @@ public class SiteLocalDbWiringTests : IDisposable
db.ReplicatedTables.Keys.OrderBy(k => k, StringComparer.Ordinal).ToArray());
}
/// <summary>Verifies that the central-only notification tables are not replicated.</summary>
[Fact]
public void Site_LocalDb_DoesNotReplicateTheCentralOnlyNotificationTables()
{
@@ -305,6 +320,7 @@ public class SiteLocalDbWiringTests : IDisposable
Assert.DoesNotContain("smtp_configurations", db.ReplicatedTables.Keys);
}
/// <summary>Verifies that the node-local mirrored secrets ledger is not replicated.</summary>
[Fact]
public void Site_LocalDb_DoesNotReplicateTheNodeLocalMirroredSecretsLedger()
{
@@ -316,6 +332,7 @@ public class SiteLocalDbWiringTests : IDisposable
Assert.DoesNotContain("mirrored_secrets", db.ReplicatedTables.Keys);
}
/// <summary>Verifies that the replicated Phase 2 tables have their expected primary keys.</summary>
[Fact]
public void Site_LocalDb_ReplicatedPhase2TablesHaveTheirExpectedPrimaryKeys()
{
@@ -350,6 +367,7 @@ public class SiteLocalDbWiringTests : IDisposable
return names;
}
/// <summary>Verifies that the site LocalDb creates the configured database file.</summary>
[Fact]
public void Site_LocalDb_CreatesTheConfiguredFile()
{
@@ -26,11 +26,20 @@ public class SitePskProviderTests
private sealed class StaticOptionsMonitor(CommunicationOptions value)
: IOptionsMonitor<CommunicationOptions>
{
/// <summary>Gets the fixed options instance.</summary>
public CommunicationOptions CurrentValue => value;
/// <summary>Returns the fixed options instance regardless of name.</summary>
/// <param name="name">The named-options key, ignored.</param>
/// <returns>The fixed options instance.</returns>
public CommunicationOptions Get(string? name) => value;
/// <summary>Does nothing; the fake never raises change notifications.</summary>
/// <param name="listener">The change listener, ignored.</param>
/// <returns>Always null.</returns>
public IDisposable? OnChange(Action<CommunicationOptions, string?> listener) => null;
}
/// <summary>Verifies that the key is resolved from the secret store under the site-qualified name.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ResolvesFromTheSecretStore_UnderTheSiteQualifiedName()
{
@@ -43,6 +52,8 @@ public class SitePskProviderTests
Assert.Equal("key-for-a", psk);
}
/// <summary>Verifies that a configured key map takes precedence so a host without a master key can still dial.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ConfiguredMapWins_SoAHostWithNoMasterKeyCanStillDial()
{
@@ -62,6 +73,8 @@ public class SitePskProviderTests
await resolver.DidNotReceive().GetAsync(Arg.Any<SecretName>(), Arg.Any<CancellationToken>());
}
/// <summary>Verifies that the store is still consulted for a site missing from the configured map.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task StoreIsStillConsulted_ForASiteMissingFromTheMap()
{
@@ -78,6 +91,8 @@ public class SitePskProviderTests
Assert.Equal("key-for-b", psk);
}
/// <summary>Verifies that a missing secret throws and never yields an empty key.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task MissingSecret_Throws_AndNeverYieldsAnEmptyKey()
{
@@ -95,6 +110,8 @@ public class SitePskProviderTests
Assert.Contains("SitePsks", ex.Message);
}
/// <summary>Verifies that an empty secret is treated as missing.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task EmptySecret_IsTreatedAsMissing()
{
@@ -105,6 +122,8 @@ public class SitePskProviderTests
async () => await Create(resolver).GetAsync("site-a", CancellationToken.None));
}
/// <summary>Verifies that a faulting store surfaces as a key-not-configured error rather than a crypto error.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AFaultingStore_SurfacesAsKeyNotConfigured_NotAsACryptoError()
{
@@ -120,6 +139,8 @@ public class SitePskProviderTests
Assert.Contains("SB-GRPC-PSK-site-a", ex.Message);
}
/// <summary>Verifies that successful resolves are cached so not every call hits the store.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SuccessfulResolvesAreCached_SoEveryCallDoesNotHitTheStore()
{
@@ -134,6 +155,8 @@ public class SitePskProviderTests
await resolver.Received(1).GetAsync(Arg.Any<SecretName>(), Arg.Any<CancellationToken>());
}
/// <summary>Verifies that failures are not cached so a newly seeded key is picked up without a restart.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task FailuresAreNotCached_SoASeededKeyIsPickedUpWithoutARestart()
{
@@ -148,6 +171,8 @@ public class SitePskProviderTests
Assert.Equal("seeded-later", await provider.GetAsync("site-a", CancellationToken.None));
}
/// <summary>Verifies that invalidating drops the cached key so a rotated key is read.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Invalidate_DropsTheCachedKey_SoARotatedKeyIsRead()
{
@@ -162,6 +187,8 @@ public class SitePskProviderTests
Assert.Equal("rotated", await provider.GetAsync("site-a", CancellationToken.None));
}
/// <summary>Verifies that keys are scoped per site so one site never presents another site's key.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task KeysAreScopedPerSite_SoOneSiteNeverPresentsAnothersKey()
{
@@ -51,10 +51,11 @@ public class StandbyHealthCheckLogNoiseTests
.ForContext("SourceContext", category)
.Write(level, Template, checkName, status, 1.0, "some message");
/// <summary>Verifies that the standby active-node verdict is not logged.</summary>
[Fact]
public void StandbyActiveNodeVerdict_IsNotLogged()
{
// The row-71 regression: "Health check active-node with status Unhealthy … 'not the
// The regression: "Health check active-node with status Unhealthy … 'not the
// site pair primary (standby)'" at Error, once per probe, forever.
var sink = new InMemorySink();
var logger = BuildLogger(sink);
@@ -65,6 +66,8 @@ public class StandbyHealthCheckLogNoiseTests
Assert.Empty(sink.LogEvents);
}
/// <summary>Verifies that a genuine failure of a health check still logs at Error.</summary>
/// <param name="checkName">The name of the failing health check.</param>
[Theory]
[InlineData("localdb")]
[InlineData("akka-cluster")]
@@ -85,6 +88,7 @@ public class StandbyHealthCheckLogNoiseTests
Assert.Equal(LogEventLevel.Error, evt.Level);
}
/// <summary>Verifies that a healthy active-node verdict is unaffected.</summary>
[Fact]
public void ActiveNodeHealthyVerdict_IsUnaffected()
{
@@ -99,6 +103,7 @@ public class StandbyHealthCheckLogNoiseTests
Assert.Single(sink.LogEvents);
}
/// <summary>Verifies that a degraded active-node verdict is unaffected.</summary>
[Fact]
public void ActiveNodeDegradedVerdict_IsUnaffected()
{
@@ -113,6 +118,7 @@ public class StandbyHealthCheckLogNoiseTests
Assert.Single(sink.LogEvents);
}
/// <summary>Verifies that an error mentioning the active node from another category is not suppressed.</summary>
[Fact]
public void AnErrorMentioningActiveNode_FromAnotherCategory_IsNotSuppressed()
{
@@ -131,6 +137,7 @@ public class StandbyHealthCheckLogNoiseTests
Assert.Single(sink.LogEvents);
}
/// <summary>Verifies that ordinary errors with no health-check properties are not suppressed.</summary>
[Fact]
public void OrdinaryErrorsWithNoHealthCheckProperties_AreNotSuppressed()
{
@@ -142,6 +149,7 @@ public class StandbyHealthCheckLogNoiseTests
Assert.Single(sink.LogEvents);
}
/// <summary>Verifies that the predicate rejects a null event.</summary>
[Fact]
public void Predicate_RejectsANullEvent()
{
@@ -3,12 +3,14 @@ using Microsoft.Extensions.Logging.Abstractions;
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
/// <summary>
/// Host-010: startup preconditions (database migration) must tolerate a database
/// Startup preconditions (database migration) must tolerate a database
/// that is briefly unavailable at boot — common when an app container and its DB
/// container start together — via a bounded retry with backoff.
/// </summary>
public class StartupRetryTests
{
/// <summary>Verifies that an operation that succeeds on the first try runs exactly once.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ExecuteWithRetry_SucceedsFirstTry_RunsOnce()
{
@@ -23,6 +25,8 @@ public class StartupRetryTests
Assert.Equal(1, attempts);
}
/// <summary>Verifies that transient failures are retried until the operation succeeds.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ExecuteWithRetry_TransientFailures_RetriesUntilSuccess()
{
@@ -43,6 +47,8 @@ public class StartupRetryTests
Assert.Equal(3, attempts);
}
/// <summary>Verifies that exhausting all attempts rethrows the last exception.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ExecuteWithRetry_ExhaustsAttempts_RethrowsLastException()
{
@@ -64,10 +70,12 @@ public class StartupRetryTests
Assert.Equal("failure 3", ex.Message);
}
/// <summary>Verifies that a non-transient failure is rethrown after a single attempt.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ExecuteWithRetry_NonTransientFailure_RethrowsAfterSingleAttempt()
{
// Host-015: a permanent failure (e.g. a schema-version mismatch) must NOT be
// A permanent failure (e.g. a schema-version mismatch) must NOT be
// retried — retrying it cannot succeed and only delays the fatal exit by
// minutes. The isTransient predicate classifies it as non-retryable, so the
// operation runs exactly once before the exception propagates.
@@ -89,6 +97,8 @@ public class StartupRetryTests
Assert.Equal("permanent schema mismatch", ex.Message);
}
/// <summary>Verifies that a transient failure followed by a permanent one stops at the permanent failure.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ExecuteWithRetry_TransientThenPermanent_StopsAtPermanent()
{
@@ -3,7 +3,7 @@ using Microsoft.Extensions.Configuration;
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
/// <summary>
/// WP-11: Tests for StartupValidator configuration validation.
/// Tests for StartupValidator configuration validation.
/// </summary>
public class StartupValidatorTests
{
@@ -38,18 +38,20 @@ public class StartupValidatorTests
["ScadaBridge:Database:SiteDbPath"] = "./data/scadabridge.db",
["ScadaBridge:Cluster:SeedNodes:0"] = "akka.tcp://scadabridge@site-a-node1:8082",
["ScadaBridge:Cluster:SeedNodes:1"] = "akka.tcp://scadabridge@site-a-node2:8082",
// T0.3: the gRPC control plane is fail-closed, so a Site node without a preshared
// The gRPC control plane is fail-closed, so a Site node without a preshared
// key serves nothing while still looking healthy. Required at boot for that reason.
["ScadaBridge:Communication:GrpcPsk"] = "test-site-control-plane-key",
// Phase 4: gRPC (CentralControlService) is the only site→central transport, so a Site
// node must list at least one central gRPC endpoint to dial (no Akka fallback remains).
["ScadaBridge:Communication:CentralGrpcEndpoints:0"] = "http://central-a:8083",
// arch-review remediation WP1.2: the site hot-path audit writer's SQLite file has no
// The site hot-path audit writer's SQLite file has no
// default path (mirrors LocalDb:Path) — required so it lands on the mounted data volume,
// not the container's ephemeral overlayfs.
["AuditLog:SiteWriter:DatabasePath"] = "/app/data/auditlog.db",
};
/// <summary>Verifies that a valid central config passes validation.</summary>
/// <returns>The result.</returns>
[Fact]
public void ValidCentralConfig_PassesValidation()
{
@@ -58,6 +60,8 @@ public class StartupValidatorTests
Assert.Null(ex);
}
/// <summary>Verifies that a valid site config passes validation.</summary>
/// <returns>The result.</returns>
[Fact]
public void ValidSiteConfig_PassesValidation()
{
@@ -66,6 +70,8 @@ public class StartupValidatorTests
Assert.Null(ex);
}
/// <summary>Verifies that a site without gRPC PSK fails validation.</summary>
/// <returns>The result.</returns>
[Fact]
public void SiteWithoutGrpcPsk_FailsValidation()
{
@@ -81,6 +87,8 @@ public class StartupValidatorTests
Assert.Contains("GrpcPsk", ex.Message);
}
/// <summary>Verifies that a site with a blank gRPC PSK fails validation.</summary>
/// <returns>The result.</returns>
[Fact]
public void SiteWithBlankGrpcPsk_FailsValidation()
{
@@ -94,6 +102,8 @@ public class StartupValidatorTests
Assert.Contains("GrpcPsk", ex.Message);
}
/// <summary>Verifies that a central node without gRPC PSK passes validation.</summary>
/// <returns>The result.</returns>
[Fact]
public void CentralWithoutGrpcPsk_PassesValidation()
{
@@ -104,6 +114,8 @@ public class StartupValidatorTests
Assert.Null(Record.Exception(() => StartupValidator.Validate(config)));
}
/// <summary>Verifies that a site without central gRPC endpoints fails validation.</summary>
/// <returns>The result.</returns>
[Fact]
public void SiteWithoutCentralGrpcEndpoints_FailsValidation()
{
@@ -118,6 +130,8 @@ public class StartupValidatorTests
Assert.Contains("CentralGrpcEndpoints", ex.Message);
}
/// <summary>Verifies that a central node without central gRPC endpoints passes validation.</summary>
/// <returns>The result.</returns>
[Fact]
public void CentralWithoutCentralGrpcEndpoints_PassesValidation()
{
@@ -128,10 +142,12 @@ public class StartupValidatorTests
Assert.Null(Record.Exception(() => StartupValidator.Validate(config)));
}
/// <summary>Verifies that a site without audit log database path fails validation.</summary>
/// <returns>The result.</returns>
[Fact]
public void SiteWithoutAuditLogDatabasePath_FailsValidation()
{
// arch-review remediation WP1.2: SqliteAuditWriterOptions.DatabasePath has no default
// SqliteAuditWriterOptions.DatabasePath has no default
// (mirrors LocalDb:Path). An unset value used to fall back to a bare "auditlog.db"
// resolved relative to CWD — on the docker rig, the container's ephemeral overlayfs —
// so the node booted, looked healthy, and lost its pending audit backlog on every
@@ -144,6 +160,8 @@ public class StartupValidatorTests
Assert.Contains("DatabasePath", ex.Message);
}
/// <summary>Verifies that a site with a blank audit log database path fails validation.</summary>
/// <returns>The result.</returns>
[Fact]
public void SiteWithBlankAuditLogDatabasePath_FailsValidation()
{
@@ -155,6 +173,8 @@ public class StartupValidatorTests
Assert.Contains("DatabasePath", ex.Message);
}
/// <summary>Verifies that a central node without audit log database path passes validation.</summary>
/// <returns>The result.</returns>
[Fact]
public void CentralWithoutAuditLogDatabasePath_PassesValidation()
{
@@ -165,6 +185,8 @@ public class StartupValidatorTests
Assert.Null(Record.Exception(() => StartupValidator.Validate(config)));
}
/// <summary>Verifies that a missing role fails validation.</summary>
/// <returns>The result.</returns>
[Fact]
public void MissingRole_FailsValidation()
{
@@ -176,6 +198,8 @@ public class StartupValidatorTests
Assert.Contains("Role must be 'Central' or 'Site'", ex.Message);
}
/// <summary>Verifies that an invalid role fails validation.</summary>
/// <returns>The result.</returns>
[Fact]
public void InvalidRole_FailsValidation()
{
@@ -187,6 +211,8 @@ public class StartupValidatorTests
Assert.Contains("Role must be 'Central' or 'Site'", ex.Message);
}
/// <summary>Verifies that an empty hostname fails validation.</summary>
/// <returns>The result.</returns>
[Fact]
public void EmptyHostname_FailsValidation()
{
@@ -198,6 +224,8 @@ public class StartupValidatorTests
Assert.Contains("NodeHostname is required", ex.Message);
}
/// <summary>Verifies that a missing hostname fails validation.</summary>
/// <returns>The result.</returns>
[Fact]
public void MissingHostname_FailsValidation()
{
@@ -209,6 +237,9 @@ public class StartupValidatorTests
Assert.Contains("NodeHostname is required", ex.Message);
}
/// <summary>Verifies that an invalid port fails validation.</summary>
/// <param name="port">The port.</param>
/// <returns>The result.</returns>
[Theory]
[InlineData("0")]
[InlineData("-1")]
@@ -225,6 +256,9 @@ public class StartupValidatorTests
Assert.Contains("RemotingPort must be 1-65535", ex.Message);
}
/// <summary>Verifies that a valid port passes validation.</summary>
/// <param name="port">The port.</param>
/// <returns>The result.</returns>
[Theory]
[InlineData("1")]
[InlineData("8081")]
@@ -243,6 +277,8 @@ public class StartupValidatorTests
Assert.Null(ex);
}
/// <summary>Verifies that a site without a site id fails validation.</summary>
/// <returns>The result.</returns>
[Fact]
public void Site_MissingSiteId_FailsValidation()
{
@@ -254,6 +290,8 @@ public class StartupValidatorTests
Assert.Contains("SiteId is required for Site nodes", ex.Message);
}
/// <summary>Verifies that a central node missing configuration database fails validation.</summary>
/// <returns>The result.</returns>
[Fact]
public void Central_MissingConfigurationDb_FailsValidation()
{
@@ -265,14 +303,16 @@ public class StartupValidatorTests
Assert.Contains("ConfigurationDb connection string required for Central", ex.Message);
}
/// <summary>Verifies that a central node missing machine data database fails validation.</summary>
/// <returns>The result.</returns>
[Fact]
public void Central_MissingMachineDataDb_FailsValidation()
{
// Reverts Host-008. REQ-HOST-3/REQ-HOST-4 require MachineDataDb to be
// The host requirements require MachineDataDb to be
// validated at startup for Central nodes, and the shipped docker appsettings
// (docker/central-node-a/appsettings.Central.json and central-node-b) carry
// the key. The prior Host-008 decision (which removed the Require) is reversed
// here (#17, M2.9): a missing MachineDataDb must fail fast with a clear error.
// the key. The earlier decision (which removed the Require) is reversed
// here: a missing MachineDataDb must fail fast with a clear error.
var values = ValidCentralConfig();
values.Remove("ScadaBridge:Database:MachineDataDb");
var config = BuildConfig(values);
@@ -281,10 +321,12 @@ public class StartupValidatorTests
Assert.Contains("MachineDataDb connection string required for Central", ex.Message);
}
/// <summary>Verifies that a central node missing LDAP server fails validation.</summary>
/// <returns>The result.</returns>
[Fact]
public void Central_MissingLdapServer_FailsValidation()
{
// Task 1.4: the LDAP server key nests under Security:Ldap now. The pre-host
// The LDAP server key nests under Security:Ldap now. The pre-host
// preflight validates the nested key and still fails fast for Central.
var values = ValidCentralConfig();
values.Remove("ScadaBridge:Security:Ldap:Server");
@@ -294,6 +336,8 @@ public class StartupValidatorTests
Assert.Contains("Ldap:Server required for Central", ex.Message);
}
/// <summary>Verifies that a central node missing JWT signing key fails validation.</summary>
/// <returns>The result.</returns>
[Fact]
public void Central_MissingJwtSigningKey_FailsValidation()
{
@@ -305,6 +349,8 @@ public class StartupValidatorTests
Assert.Contains("JwtSigningKey required for Central", ex.Message);
}
/// <summary>Verifies that a central node missing API key pepper fails validation.</summary>
/// <returns>The result.</returns>
[Fact]
public void Central_MissingApiKeyPepper_FailsValidation()
{
@@ -319,6 +365,8 @@ public class StartupValidatorTests
Assert.Contains("ApiKeyPepper", ex.Message);
}
/// <summary>Verifies that a central node with a short API key pepper fails validation.</summary>
/// <returns>The result.</returns>
[Fact]
public void Central_ShortApiKeyPepper_FailsValidation()
{
@@ -331,6 +379,8 @@ public class StartupValidatorTests
Assert.Contains("ApiKeyPepper", ex.Message);
}
/// <summary>Verifies that a site does not require an API key pepper.</summary>
/// <returns>The result.</returns>
[Fact]
public void Site_ApiKeyPepper_NotRequired()
{
@@ -363,6 +413,8 @@ public class StartupValidatorTests
Assert.Null(ex);
}
/// <summary>Verifies that fewer than two seed nodes fails validation.</summary>
/// <returns>The result.</returns>
[Fact]
public void FewerThanTwoSeedNodes_FailsValidation()
{
@@ -374,6 +426,8 @@ public class StartupValidatorTests
Assert.Contains("SeedNodes must have at least 2 entries", ex.Message);
}
/// <summary>Verifies that having no seed nodes fails validation.</summary>
/// <returns>The result.</returns>
[Fact]
public void NoSeedNodes_FailsValidation()
{
@@ -386,6 +440,8 @@ public class StartupValidatorTests
Assert.Contains("SeedNodes must have at least 2 entries", ex.Message);
}
/// <summary>Verifies that a peer-first seed order fails validation.</summary>
/// <returns>The result.</returns>
[Fact]
public void PeerFirstSeedOrder_FailsValidation()
{
@@ -403,6 +459,8 @@ public class StartupValidatorTests
Assert.Contains("must list this node itself first", ex.Message);
}
/// <summary>Verifies that a self-first seed order on a site node passes validation.</summary>
/// <returns>The result.</returns>
[Fact]
public void SelfFirstSeedOrder_OnASiteNode_PassesValidation()
{
@@ -415,6 +473,8 @@ public class StartupValidatorTests
Assert.Null(ex);
}
/// <summary>Verifies that the self-first seed check matches on host and port, not just host.</summary>
/// <returns>The result.</returns>
[Fact]
public void SelfFirstSeed_MatchedOnHostAndPort_NotJustHost()
{
@@ -432,6 +492,9 @@ public class StartupValidatorTests
Assert.Contains("must list this node itself first", ex.Message);
}
/// <summary>Verifies that a site with an invalid gRPC port fails validation.</summary>
/// <param name="grpcPort">The grpc port.</param>
/// <returns>The result.</returns>
[Theory]
[InlineData("0")]
[InlineData("-1")]
@@ -447,6 +510,8 @@ public class StartupValidatorTests
Assert.Contains("GrpcPort must be 1-65535", ex.Message);
}
/// <summary>Verifies that a site with a valid gRPC port passes validation.</summary>
/// <returns>The result.</returns>
[Fact]
public void Site_ValidGrpcPort_PassesValidation()
{
@@ -458,6 +523,8 @@ public class StartupValidatorTests
Assert.Null(ex);
}
/// <summary>Verifies that an invalid gRPC port is not validated on a central node.</summary>
/// <returns>The result.</returns>
[Fact]
public void Central_InvalidGrpcPort_NotValidated()
{
@@ -469,10 +536,12 @@ public class StartupValidatorTests
Assert.Null(ex);
}
/// <summary>Verifies that a site seed node on the gRPC port fails validation.</summary>
/// <returns>The result.</returns>
[Fact]
public void Site_SeedNodeOnGrpcPort_FailsValidation()
{
// Host-004 regression: a site seed node must reference an Akka remoting
// Regression: a site seed node must reference an Akka remoting
// endpoint, never the Kestrel HTTP/2 gRPC port. A seed node whose port
// equals this node's GrpcPort would make a joining node attempt an
// Akka.Remote TCP association against the gRPC listener and fail.
@@ -485,6 +554,8 @@ public class StartupValidatorTests
Assert.Contains("must not target the gRPC port", ex.Message);
}
/// <summary>Verifies that a site seed node on the metrics port fails validation.</summary>
/// <returns>The result.</returns>
[Fact]
public void Site_SeedNodeOnMetricsPort_FailsValidation()
{
@@ -501,6 +572,8 @@ public class StartupValidatorTests
Assert.Contains("must not target the metrics port", ex.Message);
}
/// <summary>Verifies that a site seed node on the default metrics port fails validation.</summary>
/// <returns>The result.</returns>
[Fact]
public void Site_SeedNodeOnDefaultMetricsPort_FailsValidation()
{
@@ -515,6 +588,8 @@ public class StartupValidatorTests
Assert.Contains("must not target the metrics port", ex.Message);
}
/// <summary>Verifies that a site seed node on the default gRPC port fails validation.</summary>
/// <returns>The result.</returns>
[Fact]
public void Site_SeedNodeOnDefaultGrpcPort_FailsValidation()
{
@@ -528,6 +603,8 @@ public class StartupValidatorTests
Assert.Contains("must not target the gRPC port", ex.Message);
}
/// <summary>Verifies that site seed nodes on the remoting port pass validation.</summary>
/// <returns>The result.</returns>
[Fact]
public void Site_SeedNodesOnRemotingPort_PassesValidation()
{
@@ -542,6 +619,8 @@ public class StartupValidatorTests
Assert.Null(ex);
}
/// <summary>Verifies that a central seed node on port 8083 passes validation.</summary>
/// <returns>The result.</returns>
[Fact]
public void Central_SeedNodeOnPort8083_PassesValidation()
{
@@ -555,10 +634,12 @@ public class StartupValidatorTests
Assert.Null(ex);
}
/// <summary>Verifies that a site gRPC port equal to the remoting port fails validation.</summary>
/// <returns>The result.</returns>
[Fact]
public void Site_GrpcPortEqualsRemotingPort_FailsValidation()
{
// Host-007 regression: REQ-HOST-4 requires GrpcPort to differ from
// Regression: the host requirements require GrpcPort to differ from
// RemotingPort. Identical values cause Kestrel and Akka.Remote to
// contend for the same port at runtime.
var values = ValidSiteConfig();
@@ -570,6 +651,8 @@ public class StartupValidatorTests
Assert.Contains("GrpcPort must differ from RemotingPort", ex.Message);
}
/// <summary>Verifies that a site default gRPC port equal to the remoting port fails validation.</summary>
/// <returns>The result.</returns>
[Fact]
public void Site_DefaultGrpcPortEqualsRemotingPort_FailsValidation()
{
@@ -583,6 +666,8 @@ public class StartupValidatorTests
Assert.Contains("GrpcPort must differ from RemotingPort", ex.Message);
}
/// <summary>Verifies that a site gRPC port that differs from the remoting port passes validation.</summary>
/// <returns>The result.</returns>
[Fact]
public void Site_GrpcPortDiffersFromRemotingPort_PassesValidation()
{
@@ -595,6 +680,9 @@ public class StartupValidatorTests
Assert.Null(ex);
}
/// <summary>Verifies that a site with an invalid metrics port fails validation.</summary>
/// <param name="metricsPort">The metrics port.</param>
/// <returns>The result.</returns>
[Theory]
[InlineData("0")]
[InlineData("-1")]
@@ -610,6 +698,8 @@ public class StartupValidatorTests
Assert.Contains("MetricsPort must be 1-65535", ex.Message);
}
/// <summary>Verifies that a site with a valid metrics port passes validation.</summary>
/// <returns>The result.</returns>
[Fact]
public void Site_ValidMetricsPort_PassesValidation()
{
@@ -621,10 +711,12 @@ public class StartupValidatorTests
Assert.Null(ex);
}
/// <summary>Verifies that a site metrics port equal to the remoting port fails validation.</summary>
/// <returns>The result.</returns>
[Fact]
public void Site_MetricsPortEqualsRemotingPort_FailsValidation()
{
// Host-007 regression: the Kestrel metrics (HTTP/1.1) listener port must
// Regression: the Kestrel metrics (HTTP/1.1) listener port must
// differ from RemotingPort. Identical values cause the metrics listener
// and Akka.Remote to contend for the same port at runtime.
var values = ValidSiteConfig();
@@ -636,10 +728,12 @@ public class StartupValidatorTests
Assert.Contains("MetricsPort must differ from RemotingPort", ex.Message);
}
/// <summary>Verifies that a site metrics port equal to the gRPC port fails validation.</summary>
/// <returns>The result.</returns>
[Fact]
public void Site_MetricsPortEqualsGrpcPort_FailsValidation()
{
// Host-007 regression: the metrics listener port must differ from GrpcPort.
// Regression: the metrics listener port must differ from GrpcPort.
var values = ValidSiteConfig();
values["ScadaBridge:Node:GrpcPort"] = "8083";
values["ScadaBridge:Node:MetricsPort"] = "8083";
@@ -649,6 +743,8 @@ public class StartupValidatorTests
Assert.Contains("MetricsPort must differ from GrpcPort", ex.Message);
}
/// <summary>Verifies that a site default metrics port equal to the remoting port fails validation.</summary>
/// <returns>The result.</returns>
[Fact]
public void Site_DefaultMetricsPortEqualsRemotingPort_FailsValidation()
{
@@ -668,6 +764,8 @@ public class StartupValidatorTests
Assert.Contains("MetricsPort must differ from RemotingPort", ex.Message);
}
/// <summary>Verifies that a site metrics port that differs from the remoting and gRPC ports passes validation.</summary>
/// <returns>The result.</returns>
[Fact]
public void Site_MetricsPortDiffersFromRemotingAndGrpc_PassesValidation()
{
@@ -681,6 +779,8 @@ public class StartupValidatorTests
Assert.Null(ex);
}
/// <summary>Verifies that an invalid metrics port is not validated on a central node.</summary>
/// <returns>The result.</returns>
[Fact]
public void Central_InvalidMetricsPort_NotValidated()
{
@@ -694,6 +794,8 @@ public class StartupValidatorTests
Assert.Null(ex);
}
/// <summary>Verifies that collecting warnings warns for a two-seed central node on SQLite.</summary>
/// <returns>The result.</returns>
[Fact]
public void CollectWarnings_WarnsForTwoSeedCentralOnSqlite()
{
@@ -713,6 +815,8 @@ public class StartupValidatorTests
Assert.Contains("Provider=SqlServer", warning);
}
/// <summary>Verifies that collecting warnings warns when the provider is explicitly SQLite.</summary>
/// <returns>The result.</returns>
[Fact]
public void CollectWarnings_WarnsWhenProviderExplicitlySqlite()
{
@@ -726,6 +830,8 @@ public class StartupValidatorTests
Assert.Single(warnings);
}
/// <summary>Verifies that collecting warnings is silent for SQL Server.</summary>
/// <returns>The result.</returns>
[Fact]
public void CollectWarnings_SilentForSqlServer()
{
@@ -739,10 +845,12 @@ public class StartupValidatorTests
Assert.Empty(warnings);
}
/// <summary>Verifies that a clustered central node on a SQLite secret store produces a warning.</summary>
/// <returns>The result.</returns>
[Fact]
public void ClusteredCentral_SqliteSecretStore_Warns()
{
// Task 5: a clustered central left on the per-node SQLite secret store silently drifts
// A clustered central left on the per-node SQLite secret store silently drifts
// the moment anyone edits a secret on only one node, then ships an inconsistent set to
// sites. ApiKeyStore:Provider is set to SqlServer here to isolate this warning from the
// pre-existing one above.
@@ -758,6 +866,8 @@ public class StartupValidatorTests
Assert.Contains("Secrets:Store:Provider=SqlServer", warning);
}
/// <summary>Verifies that a clustered central node on a SQL Server secret store produces no warning.</summary>
/// <returns>The result.</returns>
[Fact]
public void ClusteredCentral_SqlServerSecretStore_Silent()
{
@@ -772,7 +882,7 @@ public class StartupValidatorTests
}
/// <summary>
/// Task 11: secrets replication (the gRPC hub, and the SQL-Server ReplicatingSecretStore mode
/// Secrets replication (the gRPC hub, and the SQL-Server ReplicatingSecretStore mode
/// that preceded it) is retired in ScadaBridge — sites receive secrets exclusively via Deploy
/// Artifacts, and a replicated site store would publish DeployedSecretsMirror's local deletes
/// fleet-wide. Checked on BOTH roles: a Site used to be the gRPC hub's follower half, so the
@@ -781,10 +891,11 @@ public class StartupValidatorTests
/// because CollectWarnings never runs with this flag true on a config that also passed
/// Validate.
/// </summary>
/// <param name="value">The rejected replication flag value.</param>
[Theory]
[InlineData("true")]
[InlineData("True")]
// Review round 1, Minor #3: these are not valid booleans. GetValue<bool> used to throw a raw
// These are not valid booleans. GetValue<bool> used to throw a raw
// OptionsValidationException-style binder crash on them instead of the designed message; the
// key is read as a raw string now, so any non-"false" value is "set" and rejected the same way.
[InlineData("yes")]
@@ -803,6 +914,8 @@ public class StartupValidatorTests
Assert.Contains("central uses Secrets:Store:Provider=SqlServer", ex.Message);
}
/// <summary>Verifies that enabling secrets replication is rejected on a site.</summary>
/// <returns>The result.</returns>
[Fact]
public void ReplicationEnabled_IsRejected_OnSite()
{
@@ -816,6 +929,8 @@ public class StartupValidatorTests
"Secrets replication (the gRPC hub) is retired in ScadaBridge", ex.Message);
}
/// <summary>Verifies that disabling secrets replication passes validation.</summary>
/// <returns>The result.</returns>
[Fact]
public void ReplicationDisabled_PassesValidation()
{
@@ -830,6 +945,8 @@ public class StartupValidatorTests
Assert.Null(ex);
}
/// <summary>Verifies that a case-insensitive replication-disabled value passes validation.</summary>
/// <returns>The result.</returns>
[Fact]
public void ReplicationDisabled_CaseInsensitive_PassesValidation()
{
@@ -843,7 +960,7 @@ public class StartupValidatorTests
}
/// <summary>
/// Deliberately stricter than <c>bool</c> parsing (review round 1, Minor #3): only an absent
/// Deliberately stricter than <c>bool</c> parsing (review feedback): only an absent
/// key or exactly (case-insensitively) <c>"false"</c> is safe. <c>"0"</c> is a legal
/// <c>bool.TryParse</c>-adjacent falsy spelling in some conventions but is NOT exempted here —
/// a leftover key this malformed should surface the retirement message, not silently pass.
@@ -862,8 +979,8 @@ public class StartupValidatorTests
}
/// <summary>
/// Review round 1, Minor #4: a leftover <c>Secrets:Replication</c>/<c>Secrets:GrpcHub</c> key
/// from before Task 11 is not itself a boot failure (Validate already rejects Enabled=true
/// A leftover <c>Secrets:Replication</c>/<c>Secrets:GrpcHub</c> key
/// from before secrets replication was retired is not itself a boot failure (Validate already rejects Enabled=true
/// outright), but every OTHER key under either section is now silently ignored — worth a
/// Warning so an operator cleans up QA/prod appsettings.
/// </summary>
@@ -882,6 +999,8 @@ public class StartupValidatorTests
&& w.Contains("the secrets hub is retired; remove them", StringComparison.Ordinal));
}
/// <summary>Verifies that collecting warnings warns for a leftover gRPC hub key.</summary>
/// <returns>The result.</returns>
[Fact]
public void CollectWarnings_WarnsForLeftoverGrpcHubKey()
{
@@ -896,6 +1015,8 @@ public class StartupValidatorTests
w.Contains("Secrets:Replication / Secrets:GrpcHub settings are ignored", StringComparison.Ordinal));
}
/// <summary>Verifies that collecting warnings warns for leftover keys on a site too.</summary>
/// <returns>The result.</returns>
[Fact]
public void CollectWarnings_WarnsForLeftoverKeys_OnSiteToo()
{
@@ -911,6 +1032,8 @@ public class StartupValidatorTests
w.Contains("Secrets:Replication / Secrets:GrpcHub settings are ignored", StringComparison.Ordinal));
}
/// <summary>Verifies that collecting warnings is silent for a bare replication-enabled false value.</summary>
/// <returns>The result.</returns>
[Fact]
public void CollectWarnings_SilentForBareReplicationEnabledFalse()
{
@@ -928,6 +1051,8 @@ public class StartupValidatorTests
w.Contains("Secrets:Replication / Secrets:GrpcHub settings are ignored", StringComparison.Ordinal));
}
/// <summary>Verifies that collecting warnings is silent when neither section is present.</summary>
/// <returns>The result.</returns>
[Fact]
public void CollectWarnings_SilentWhenNeitherSectionPresent()
{
@@ -941,6 +1066,8 @@ public class StartupValidatorTests
w.Contains("Secrets:Replication / Secrets:GrpcHub settings are ignored", StringComparison.Ordinal));
}
/// <summary>Verifies that collecting warnings is silent for a single-seed central node.</summary>
/// <returns>The result.</returns>
[Fact]
public void CollectWarnings_SilentForSingleSeedCentral()
{
@@ -955,6 +1082,8 @@ public class StartupValidatorTests
Assert.Empty(warnings);
}
/// <summary>Verifies that collecting warnings is silent for a site.</summary>
/// <returns>The result.</returns>
[Fact]
public void CollectWarnings_SilentForSite()
{
@@ -965,6 +1094,8 @@ public class StartupValidatorTests
Assert.Empty(warnings);
}
/// <summary>Verifies that validation rejects an unknown API key store provider.</summary>
/// <returns>The result.</returns>
[Fact]
public void Validate_RejectsUnknownApiKeyStoreProvider()
{
@@ -976,6 +1107,8 @@ public class StartupValidatorTests
Assert.Contains("ScadaBridge:InboundApi:ApiKeyStore:Provider", ex.Message);
}
/// <summary>Verifies that validation accepts the API key store provider case-insensitively.</summary>
/// <returns>The result.</returns>
[Fact]
public void Validate_AcceptsProviderCaseInsensitively()
{
@@ -987,6 +1120,8 @@ public class StartupValidatorTests
Assert.Null(ex);
}
/// <summary>Verifies that an unknown secret store provider fails validation.</summary>
/// <returns>The result.</returns>
[Fact]
public void UnknownSecretStoreProvider_Fails()
{
@@ -998,6 +1133,8 @@ public class StartupValidatorTests
Assert.Contains("Secrets:Store:Provider", ex.Message);
}
/// <summary>Verifies that validation accepts the secret store provider case-insensitively.</summary>
/// <returns>The result.</returns>
[Fact]
public void Validate_AcceptsSecretStoreProviderCaseInsensitively()
{
@@ -1009,6 +1146,8 @@ public class StartupValidatorTests
Assert.Null(ex);
}
/// <summary>Verifies that multiple errors are all reported.</summary>
/// <returns>The result.</returns>
[Fact]
public void MultipleErrors_AllReported()
{
@@ -11,7 +11,7 @@ using ZB.MOM.WW.ScadaBridge.Host.Actors;
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
/// <summary>
/// PLAN-01 Task 20 (review 01 underdeveloped #2): with SBR down-if-alone +
/// With SBR down-if-alone +
/// run-coordinated-shutdown-when-down live, a self-downed node terminates its
/// ActorSystem out from under the still-running Host process. Unless the process
/// then exits, the node idles forever — serving nothing, restarted by nobody.
@@ -53,6 +53,8 @@ public sealed class UnexpectedTerminationTests
lifetime);
}
/// <summary>Verifies that the host application is stopped when the actor system terminates outside of a stop request.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ActorSystemTerminatedOutsideStopAsync_StopsHostApplication()
{
@@ -69,6 +71,8 @@ public sealed class UnexpectedTerminationTests
lifetime.Received(1).StopApplication();
}
/// <summary>Verifies that a normal stop does not trigger an application stop.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task StopAsync_DoesNotTriggerStopApplication()
{
@@ -23,9 +23,9 @@ namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
/// <para>
/// The replication engine is registered unconditionally and idles, so <c>ISyncStatus</c>
/// still answers <c>Connected=false</c> with a real <c>0</c> backlog. Publishing that pair
/// is the row-80 bug: a dashboard reader sees <c>connected: false</c> on a perfectly healthy
/// is the bug: a dashboard reader sees <c>connected: false</c> on a perfectly healthy
/// unreplicated site and reads a fault, and central's null branches — the conditional KPI
/// emission and the sustained-badge anchor exclusion built for register row 33 — never
/// emission and the sustained-badge anchor exclusion built for unreplicated sites — never
/// receive the null they exist for, so unreplicated sites emitted a misleading
/// <c>localDbReplicationConnected=0</c> series.
/// </para>
@@ -42,6 +42,7 @@ public class UnreplicatedSiteLocalDbHealthTests : IDisposable
private readonly string _tempSiteDbPath;
private readonly string _tempTrackingDbPath;
/// <summary>Builds an unreplicated site host over a temporary LocalDb file.</summary>
public UnreplicatedSiteLocalDbHealthTests()
{
var stamp = Guid.NewGuid();
@@ -79,6 +80,7 @@ public class UnreplicatedSiteLocalDbHealthTests : IDisposable
_host = builder.Build();
}
/// <summary>Releases the resources used by this instance.</summary>
public void Dispose()
{
(_host as IDisposable)?.Dispose();
@@ -90,10 +92,11 @@ public class UnreplicatedSiteLocalDbHealthTests : IDisposable
GC.SuppressFinalize(this);
}
/// <summary>Verifies that an unreplicated site reports null for both LocalDb health fields.</summary>
[Fact]
public void UnreplicatedSite_HealthReport_ReportsNullForBothLocalDbFields()
{
// The row-80 regression, end-to-end through the real container and the real
// The regression, end-to-end through the real container and the real
// collector. Before the fix this produced false/0.
var reporter = _host.Services.GetServices<IHostedService>()
.OfType<LocalDbReplicationStatusReporter>()
@@ -107,6 +110,7 @@ public class UnreplicatedSiteLocalDbHealthTests : IDisposable
Assert.Null(report.LocalDbOplogBacklog);
}
/// <summary>Verifies that an unreplicated site still registers the health bridge.</summary>
[Fact]
public void UnreplicatedSite_StillRegistersTheHealthBridge()
{
@@ -119,10 +123,11 @@ public class UnreplicatedSiteLocalDbHealthTests : IDisposable
Assert.Contains(hosted, h => h is LocalDbReplicationStatusReporter);
}
/// <summary>Verifies that the replication engine of an unreplicated site still resolves and idles.</summary>
[Fact]
public void UnreplicatedSite_ReplicationEngine_StillResolvesAndIdles()
{
// Establishes the premise the row-80 fix rests on: the engine really does answer
// Establishes the premise the fix rests on: the engine really does answer
// false/0 here. If this ever starts returning null on its own, the gate becomes
// redundant rather than wrong — but the test above still pins the observable
// contract either way.
@@ -3,11 +3,12 @@ using System.Reflection;
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
/// <summary>
/// WP-17: Tests for Windows Service support.
/// Tests for Windows Service support.
/// Verifies UseWindowsService() is called in Program.cs.
/// </summary>
public class WindowsServiceTests
{
/// <summary>Verifies that Program.cs calls UseWindowsService.</summary>
[Fact]
public void ProgramCs_CallsUseWindowsService()
{
@@ -25,6 +26,7 @@ public class WindowsServiceTests
$"Expected UseWindowsService() to be called at least twice (Central and Site paths), found {occurrences} occurrence(s)");
}
/// <summary>Verifies that the host project references the Windows Services hosting package.</summary>
[Fact]
public void HostProject_ReferencesWindowsServicesPackage()
{