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:
@@ -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<ICachedCallTelemetryForwarder>()</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&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&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&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<AuthorizationOptions>(o => 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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user