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

Comment/XML-doc changes only:
- add summaries to ~10k test methods, fixtures, fakes and helpers
  (+ <param> on theories, <returns> on async Task methods,
  <inheritdoc/> on interface implementations the checker can resolve)
- strip ~2.6k project-tracking references (WP*, P*-*, row-NN, Task N,
  #NN, Component-0NN) from comments, keeping the explanatory text;
  reword false positives in comments only
- real docs on fakes implementing BCL/library interfaces
  (IDisposable, ILogger, IOptionsMonitor, IAsyncLifetime)
- reword the last 5 src/ false positives (CWE-1236, failures-1,
  year-0001, M365)

Only non-comment edits: four one-line nested classes split across lines
so members can carry docs. Build 0 errors / 0 warnings.
This commit is contained in:
Joseph Doherty
2026-10-07 15:05:49 -04:00
parent 0ffd4bdbea
commit 513fa1aa16
998 changed files with 17896 additions and 2395 deletions
@@ -23,7 +23,7 @@ using ZB.MOM.WW.ScadaBridge.Communication.Actors;
namespace ZB.MOM.WW.ScadaBridge.IntegrationTests.AuditLog;
/// <summary>
/// End-to-end integration test for the Audit Log (#23) site→central push path
/// End-to-end integration test for the Audit Log site→central push path
/// introduced by the "real site audit push client" follow-up.
/// </summary>
/// <remarks>
@@ -61,6 +61,7 @@ public class SiteAuditPushFlowTests : TestKit
private readonly IActorRef _central;
private readonly IActorRef _auditIngest;
/// <summary>Creates a transport that forwards each call to the supplied central actors.</summary>
/// <param name="central">Stands in for the central control plane's non-audit RPCs.</param>
/// <param name="auditIngest">
/// The central audit-ingest singleton. The two ingest RPCs go straight here, mirroring
@@ -73,12 +74,19 @@ public class SiteAuditPushFlowTests : TestKit
_auditIngest = auditIngest;
}
/// <inheritdoc />
public void SubmitNotification(NotificationSubmit message, IActorRef replyTo) => _central.Tell(message, replyTo);
/// <inheritdoc />
public void QueryNotificationStatus(NotificationStatusQuery message, IActorRef replyTo) => _central.Tell(message, replyTo);
/// <inheritdoc />
public void IngestAuditEvents(IngestAuditEventsCommand message, IActorRef replyTo) => _auditIngest.Tell(message, replyTo);
/// <inheritdoc />
public void IngestCachedTelemetry(IngestCachedTelemetryCommand message, IActorRef replyTo) => _auditIngest.Tell(message, replyTo);
/// <inheritdoc />
public void ReconcileSite(ReconcileSiteRequest message, IActorRef replyTo) => _central.Tell(message, replyTo);
/// <inheritdoc />
public void ReportSiteHealth(SiteHealthReport message, IActorRef replyTo) => _central.Tell(message, replyTo);
/// <inheritdoc />
public void SendHeartbeat(HeartbeatMessage message, IActorRef self) => _central.Tell(message, self);
}
@@ -89,13 +97,16 @@ public class SiteAuditPushFlowTests : TestKit
/// </summary>
private sealed class InMemoryAuditLogRepository : IAuditLogRepository
{
/// <inheritdoc />
public Task<IReadOnlyList<AuditEvent>> GetTraceEventsAsync(Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null)
=> throw new NotSupportedException("GetTraceEventsAsync is not exercised by this test double.");
private readonly ConcurrentDictionary<Guid, AuditEvent> _rows = new();
/// <summary>Gets a snapshot of all rows inserted so far.</summary>
public IReadOnlyCollection<AuditEvent> Rows => _rows.Values.ToList();
/// <inheritdoc />
public Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(evt);
@@ -104,38 +115,46 @@ public class SiteAuditPushFlowTests : TestKit
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<IReadOnlyList<AuditEvent>> QueryAsync(
AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default)
=> throw new NotSupportedException();
/// <inheritdoc />
public Task<long> SwitchOutPartitionAsync(DateTime monthBoundary, TimeSpan? commandTimeout = null, CancellationToken ct = default)
=> throw new NotSupportedException();
/// <inheritdoc />
public Task<long> PurgeChannelOlderThanAsync(
string channel, DateTime threshold, int batchSize, TimeSpan? commandTimeout = null, CancellationToken ct = default, int maxRows = 50_000)
=> throw new NotSupportedException();
/// <inheritdoc />
public Task<long> BackfillSourceNodeAsync(
string sentinel, DateTime before, int batchSize, CancellationToken ct = default)
=> throw new NotSupportedException();
/// <inheritdoc />
public Task<IReadOnlyList<DateTime>> GetPartitionBoundariesOlderThanAsync(
DateTime threshold, CancellationToken ct = default)
=> throw new NotSupportedException();
/// <inheritdoc />
public Task<AuditLogKpiSnapshot> GetKpiSnapshotAsync(
TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default)
=> throw new NotSupportedException();
/// <inheritdoc />
public Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
Guid executionId, CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null)
=> throw new NotSupportedException();
/// <inheritdoc />
public Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default)
=> throw new NotSupportedException();
}
// C3 (Task 2.5): canonical record via the shared factory; ForwardState is a
// Canonical record via the shared factory; ForwardState is a
// site-storage-only concern (defaulted to Pending by the SQLite writer), not a
// field on the canonical record.
private static AuditEvent NewPendingEvent(Guid id) =>
@@ -148,6 +167,8 @@ public class SiteAuditPushFlowTests : TestKit
target: "ext-system-1",
sourceSiteId: "site-1");
/// <summary>Verifies that a site audit event drains to central and the site row is flipped to forwarded.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SiteAuditEvent_DrainsToCentral_AndFlipsSiteRowToForwarded()
{
@@ -233,7 +254,7 @@ public class SiteAuditPushFlowTests : TestKit
// The site row reached AuditForwardState.Forwarded specifically —
// not merely "no longer Pending" (a Reconciled row would also leave
// ReadPendingAsync, so we assert the positive Forwarded state).
// C3 (Task 2.5): ForwardState is a site-storage-only column, no longer a
// ForwardState is a site-storage-only column, no longer a
// field on the canonical record. ReadForwardedAsync only returns rows in
// the Forwarded state, so a single match here proves the row reached it.
var forwarded = await writer.ReadForwardedAsync(256, CancellationToken.None);
@@ -8,17 +8,21 @@ using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
namespace ZB.MOM.WW.ScadaBridge.IntegrationTests;
/// <summary>
/// WP-22: Audit transactional guarantee — entity change + audit log in same transaction.
/// Audit transactional guarantee — entity change + audit log in same transaction.
/// </summary>
public class AuditTransactionTests : IClassFixture<ScadaBridgeWebApplicationFactory>
{
private readonly ScadaBridgeWebApplicationFactory _factory;
/// <summary>Initializes a new instance of the <see cref="AuditTransactionTests"/> class.</summary>
/// <param name="factory">The factory used by the tests.</param>
public AuditTransactionTests(ScadaBridgeWebApplicationFactory factory)
{
_factory = factory;
}
/// <summary>Verifies the case: audit Log, is Committed With Entity Change, in Same Transaction.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AuditLog_IsCommittedWithEntityChange_InSameTransaction()
{
@@ -54,6 +58,8 @@ public class AuditTransactionTests : IClassFixture<ScadaBridgeWebApplicationFact
Assert.Contains(auditEntries, a => a.EntityName == "test-group-audit" && a.Action == "Create");
}
/// <summary>Verifies the case: audit Log, is Not Persisted When Save Not Called.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AuditLog_IsNotPersistedWhenSaveNotCalled()
{
@@ -7,18 +7,22 @@ using ZB.MOM.WW.ScadaBridge.Security;
namespace ZB.MOM.WW.ScadaBridge.IntegrationTests;
/// <summary>
/// WP-22: Auth flow integration tests.
/// Auth flow integration tests.
/// Tests that require a running LDAP server are marked with Integration trait.
/// </summary>
public class AuthFlowTests : IClassFixture<ScadaBridgeWebApplicationFactory>
{
private readonly ScadaBridgeWebApplicationFactory _factory;
/// <summary>Initializes the test with the shared web application factory.</summary>
/// <param name="factory">The web application factory hosting the app under test.</param>
public AuthFlowTests(ScadaBridgeWebApplicationFactory factory)
{
_factory = factory;
}
/// <summary>Verifies that the login endpoint with empty credentials redirects to the login page with an error.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task LoginEndpoint_WithEmptyCredentials_RedirectsToLoginWithError()
{
@@ -37,6 +41,8 @@ public class AuthFlowTests : IClassFixture<ScadaBridgeWebApplicationFactory>
Assert.Contains("error", location, StringComparison.OrdinalIgnoreCase);
}
/// <summary>Verifies that the logout endpoint clears the cookie and redirects.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task LogoutEndpoint_ClearsCookieAndRedirects()
{
@@ -52,6 +58,7 @@ public class AuthFlowTests : IClassFixture<ScadaBridgeWebApplicationFactory>
Assert.Contains("/login", location);
}
/// <summary>Verifies that the JWT token service round-trips a generated token through validation.</summary>
[Fact]
public void JwtTokenService_GenerateAndValidate_RoundTrips()
{
@@ -79,6 +86,7 @@ public class AuthFlowTests : IClassFixture<ScadaBridgeWebApplicationFactory>
Assert.Contains("Designer", roles);
}
/// <summary>Verifies that tokens generated with site scopes include site id claims.</summary>
[Fact]
public void JwtTokenService_WithSiteScopes_IncludesSiteIdClaims()
{
@@ -99,6 +107,8 @@ public class AuthFlowTests : IClassFixture<ScadaBridgeWebApplicationFactory>
Assert.Contains("3", siteIds);
}
/// <summary>Verifies that the login endpoint with valid LDAP credentials sets the cookie and redirects.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Trait("Category", "Integration")]
[Fact]
public async Task LoginEndpoint_WithValidLdapCredentials_SetsCookieAndRedirects()
@@ -132,6 +142,8 @@ public class AuthFlowTests : IClassFixture<ScadaBridgeWebApplicationFactory>
Assert.Contains("ZB.MOM.WW.ScadaBridge.Auth", setCookieHeader);
}
/// <summary>Verifies that the login endpoint without an antiforgery token is rejected without an auth cookie.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task LoginEndpoint_WithoutAntiforgeryToken_RejectsWithoutAuthCookie()
{
@@ -28,7 +28,7 @@ using ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening;
namespace ZB.MOM.WW.ScadaBridge.IntegrationTests;
/// <summary>
/// P7-5: a bulk site deploy runs all three phases over ONE scoped
/// A bulk site deploy runs all three phases over ONE scoped
/// <c>DbContext</c>, so a persistent <c>SaveChanges</c> failure on a single
/// instance used to fail every instance that came after it.
///
@@ -62,6 +62,7 @@ public sealed class BulkDeployPoisonedContextTests : TestKit, IDisposable
private readonly PoisonedEntityInterceptor _poison = new();
private int _siteId;
/// <summary>Creates an in-memory SQLite database with the poisoning interceptor and seeds it.</summary>
public BulkDeployPoisonedContextTests()
{
_connection = new SqliteConnection("DataSource=:memory:");
@@ -92,6 +93,7 @@ public sealed class BulkDeployPoisonedContextTests : TestKit, IDisposable
/// <c>Failed</c>, not stranded), and 3 and 4 save cleanly.
/// </para>
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task BulkDeploy_PersistentSaveFailureOnOneInstance_DoesNotCascadeToTheRest()
{
@@ -135,6 +137,7 @@ public sealed class BulkDeployPoisonedContextTests : TestKit, IDisposable
/// the NEXT save re-attempts it, and
/// <c>IDeploymentManagerRepository.DetachPendingChanges</c> is what clears it.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task FailedSave_KeepsTheEntityTracked_UntilPendingChangesAreDetached()
{
@@ -253,6 +256,7 @@ public sealed class BulkDeployPoisonedContextTests : TestKit, IDisposable
.ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning))
.Options);
/// <summary>Disposes the database context and connection.</summary>
public new void Dispose()
{
_context.Dispose();
@@ -269,8 +273,11 @@ public sealed class BulkDeployPoisonedContextTests : TestKit, IDisposable
{
private string? _poisonedUniqueName;
/// <summary>Makes every save that includes the named instance fail.</summary>
/// <param name="uniqueName">The unique name of the instance whose saves should fail.</param>
public void PoisonInstanceNamed(string uniqueName) => _poisonedUniqueName = uniqueName;
/// <inheritdoc />
public override InterceptionResult<int> SavingChanges(
DbContextEventData eventData, InterceptionResult<int> result)
{
@@ -278,6 +285,7 @@ public sealed class BulkDeployPoisonedContextTests : TestKit, IDisposable
return base.SavingChanges(eventData, result);
}
/// <inheritdoc />
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData,
InterceptionResult<int> result,
@@ -305,6 +313,7 @@ public sealed class BulkDeployPoisonedContextTests : TestKit, IDisposable
/// <summary>Answers every deployment with Success, so only persistence can fail here.</summary>
private sealed class AcceptingSiteActor : ReceiveActor
{
/// <summary>Registers a handler that answers every deployment command with Success.</summary>
public AcceptingSiteActor()
{
Receive<SiteEnvelope>(env =>
@@ -325,11 +334,14 @@ public sealed class BulkDeployPoisonedContextTests : TestKit, IDisposable
/// </summary>
private sealed class SqliteContext : ScadaBridgeDbContext
{
/// <summary>Creates a context over the given options using an ephemeral data protection provider.</summary>
/// <param name="options">The EF Core context options.</param>
public SqliteContext(DbContextOptions<ScadaBridgeDbContext> options)
: base(options, new EphemeralDataProtectionProvider())
{
}
/// <inheritdoc />
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
@@ -6,7 +6,7 @@ using ZB.MOM.WW.ScadaBridge.Security;
namespace ZB.MOM.WW.ScadaBridge.IntegrationTests;
/// <summary>
/// WP-1 (Phase 8): Full-system failover testing — Central.
/// Full-system failover testing — Central.
/// Verifies that JWT tokens and deployment state survive central node failover.
/// Multi-process failover tests are marked with Integration trait for separate runs.
/// </summary>
@@ -24,6 +24,7 @@ public class CentralFailoverTests
return new JwtTokenService(options, NullLogger<JwtTokenService>.Instance);
}
/// <summary>Verifies that a JWT generated before failover validates after failover.</summary>
[Fact]
public void JwtToken_GeneratedBeforeFailover_ValidatesAfterFailover()
{
@@ -46,6 +47,7 @@ public class CentralFailoverTests
Assert.Equal("failover_test", username);
}
/// <summary>Verifies that a JWT with site scopes survives revalidation.</summary>
[Fact]
public void JwtToken_WithSiteScopes_SurvivesRevalidation()
{
@@ -67,6 +69,7 @@ public class CentralFailoverTests
Assert.Contains("site-5", siteIds);
}
/// <summary>Verifies that a JWT validated with a different signing key fails validation.</summary>
[Fact]
public void JwtToken_DifferentSigningKeys_FailsValidation()
{
@@ -85,6 +88,7 @@ public class CentralFailoverTests
Assert.Null(principal);
}
/// <summary>Verifies that optimistic concurrency on deployment status detects stale writes.</summary>
[Trait("Category", "Integration")]
[Fact]
public void DeploymentStatus_OptimisticConcurrency_DetectsStaleWrites()
@@ -101,6 +105,7 @@ public class CentralFailoverTests
Assert.Equal(Commons.Types.Enums.DeploymentStatus.Success, status2.Status);
}
/// <summary>Verifies that a JWT that expired before failover is rejected after failover.</summary>
[Fact]
public void JwtToken_ExpiredBeforeFailover_RejectedAfterFailover()
{
@@ -119,6 +124,7 @@ public class CentralFailoverTests
Assert.NotNull(expClaim);
}
/// <summary>Verifies that an idle timeout is detected.</summary>
[Fact]
public void JwtToken_IdleTimeout_Detected()
{
@@ -137,6 +143,7 @@ public class CentralFailoverTests
Assert.False(jwtService.IsIdleTimedOut(principal!));
}
/// <summary>Verifies that the refresh check detects a token near expiry.</summary>
[Fact]
public void JwtToken_ShouldRefresh_DetectsNearExpiry()
{
@@ -155,6 +162,7 @@ public class CentralFailoverTests
Assert.False(jwtService.ShouldRefresh(principal!));
}
/// <summary>Verifies that deployment status for multiple instances is tracked independently.</summary>
[Trait("Category", "Integration")]
[Fact]
public void DeploymentStatus_MultipleInstances_IndependentTracking()
@@ -25,6 +25,8 @@ namespace ZB.MOM.WW.ScadaBridge.IntegrationTests.Cluster;
/// </summary>
public class ActiveNodeSemanticsTests
{
/// <summary>Verifies that after the oldest node restarts, the surviving node stays active and the rejoining node becomes standby.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AfterOldestNodeRestarts_SurvivorStaysActive_RejoinerIsStandby()
{
@@ -10,7 +10,7 @@ using ZB.MOM.WW.ScadaBridge.Host.Actors;
namespace ZB.MOM.WW.ScadaBridge.IntegrationTests.Cluster;
/// <summary>
/// Real-ActorSystem tests for the simultaneous-cold-start split-brain guard (Gitea #33):
/// Real-ActorSystem tests for the simultaneous-cold-start split-brain guard:
/// <see cref="ClusterBootstrapCoordinator"/> + <see cref="ClusterBootstrapGuard"/>, driven exactly as
/// production drives them — the node's ActorSystem is built from the production <c>BuildHocon</c> output
/// with the guard enabled (which emits an EMPTY seed list, so Akka does not auto-join), then the real
@@ -82,6 +82,7 @@ public sealed class ClusterBootstrapCoordinatorTests : IAsyncLifetime
/// The FOUNDER (lower address) with the guard on forms a cluster alone when its partner is dead —
/// exactly as un-guarded self-first does. The guard must not regress the preferred founder.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Founder_forms_alone_when_partner_is_dead()
{
@@ -102,6 +103,7 @@ public sealed class ClusterBootstrapCoordinatorTests : IAsyncLifetime
/// If the guard's peer-first logic were wrong, this node would hang in JoinSeedNodeProcess forever —
/// the very regression a naive "always let the lower node found" rule would introduce.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Higher_node_forms_alone_when_partner_is_dead_after_probing()
{
@@ -122,6 +124,7 @@ public sealed class ClusterBootstrapCoordinatorTests : IAsyncLifetime
/// window — it is genuinely waiting/probing, not self-forming immediately (which would mean the
/// guard is inert). If this ever starts seeing a member before the window, the probe is not gating.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Higher_node_does_not_form_before_the_probe_window_expires()
{
@@ -142,6 +145,7 @@ public sealed class ClusterBootstrapCoordinatorTests : IAsyncLifetime
/// then the higher node — its probe finds the founder reachable, it commits peer-first, and the
/// pair converges to ONE cluster of two.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Higher_node_joins_a_live_founder()
{
@@ -163,11 +167,12 @@ public sealed class ClusterBootstrapCoordinatorTests : IAsyncLifetime
}
/// <summary>
/// THE HEADLINE #33 CASE. Both nodes cold-start truly simultaneously with the guard on. Without the
/// THE HEADLINE CASE. Both nodes cold-start truly simultaneously with the guard on. Without the
/// guard each would run FirstSeedNodeProcess, race, and form its own 1-node cluster (split brain).
/// With it, the founder forms and the higher node probes-then-joins, so the pair converges to
/// EXACTLY ONE cluster of two — no two oldest, no dual singletons.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Both_nodes_cold_starting_together_form_exactly_one_cluster()
{
@@ -188,8 +193,12 @@ public sealed class ClusterBootstrapCoordinatorTests : IAsyncLifetime
"the higher node must be in the SAME cluster as the founder");
}
/// <summary>Prepares the fixture before the tests run.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public Task InitializeAsync() => Task.CompletedTask;
/// <summary>Releases the resources held by this fixture asynchronously.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task DisposeAsync()
{
foreach (var c in _coordinators)
@@ -45,6 +45,8 @@ public sealed class ManualFailoverTests : IAsyncDisposable
return system;
}
/// <summary>Verifies that failover, makes, the, oldest, leave, and, the, survivor, take, over.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Failover_makes_the_oldest_leave_and_the_survivor_take_over()
{
@@ -66,6 +68,8 @@ public sealed class ManualFailoverTests : IAsyncDisposable
Assert.True(ActiveNodeEvaluator.SelfIsOldestUp(Akka.Cluster.Cluster.Get(f.NodeB), "Central"));
}
/// <summary>Verifies that failover, refuses, when, no, peer, exists.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Failover_refuses_when_no_peer_exists()
{
@@ -82,6 +86,8 @@ public sealed class ManualFailoverTests : IAsyncDisposable
Assert.True(ActiveNodeEvaluator.SelfIsOldestUp(Akka.Cluster.Cluster.Get(solo), "Central"));
}
/// <summary>Verifies that failover, dry, run, names, the, target, without, moving, the, cluster.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Failover_dry_run_names_the_target_without_moving_the_cluster()
{
@@ -98,6 +104,8 @@ public sealed class ManualFailoverTests : IAsyncDisposable
Assert.True(ActiveNodeEvaluator.SelfIsOldestUp(oldest, "Central"));
}
/// <summary>Terminates the actor systems and disposes the database environment.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async ValueTask DisposeAsync()
{
foreach (var s in _systems)
@@ -18,7 +18,7 @@ namespace ZB.MOM.WW.ScadaBridge.IntegrationTests.Cluster;
/// <item><c>keep-oldest</c> — downs the side that does NOT contain the oldest member,
/// and its <c>down-if-alone</c> escape only fires when the surviving side has ≥2
/// members (<c>KeepOldest.OldestDecision</c>: <c>otherSide == 1 &amp;&amp; thisSide &gt;= 2</c>).
/// With 1-vs-1 the younger survivor therefore takes <c>DownReachable</c> — it downs
/// With a two-node cluster the younger survivor therefore takes <c>DownReachable</c> — it downs
/// ITSELF — so only a YOUNGER-node crash is survivable.</item>
/// <item><c>auto-down</c> (production default, decision 2026-07-21) — the leader among
/// the reachable members downs the unreachable peer after the stability window, so a
@@ -30,6 +30,7 @@ public class SbrFailoverTests
{
private sealed class EchoActor : ReceiveActor
{
/// <summary>Initializes the actor to echo every message back to its sender.</summary>
public EchoActor() => ReceiveAny(msg => Sender.Tell(msg));
}
@@ -47,6 +48,8 @@ public class SbrFailoverTests
return (manager, proxy);
}
/// <summary>Verifies that when the younger node crashes hard, the split brain resolver downs it and the oldest node keeps the singleton.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task HardCrashOfYoungerNode_SbrDownsIt_AndOldestKeepsSingleton()
{
@@ -84,6 +87,8 @@ public class SbrFailoverTests
throw new Xunit.Sdk.XunitException($"Singleton stopped answering on the surviving oldest node after SBR downing: {last}");
}
/// <summary>Verifies that with auto-down, a hard crash of the oldest node lets the younger survivor take over the singleton.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AutoDown_HardCrashOfOldestNode_YoungerSurvivorTakesOverSingleton()
{
@@ -128,6 +133,8 @@ public class SbrFailoverTests
$"Singleton never migrated to the younger survivor after the oldest crashed under auto-down: {last}");
}
/// <summary>Verifies that with auto-down, a hard crash of the younger node leaves the singleton on the oldest node.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AutoDown_HardCrashOfYoungerNode_OldestKeepsSingleton()
{
@@ -68,6 +68,8 @@ public sealed class SelfFirstSeedBootstrapTests : IAsyncLifetime
return system;
}
/// <summary>Verifies that a lone node cold-starting with a dead peer forms a cluster on its own.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Lone_cold_start_forms_a_cluster_when_the_peer_is_dead()
{
@@ -81,6 +83,8 @@ public sealed class SelfFirstSeedBootstrapTests : IAsyncLifetime
Assert.Equal(MemberStatus.Up, Akka.Cluster.Cluster.Get(node).SelfMember.Status);
}
/// <summary>Verifies that listing the peer first in the seed nodes is the outage gap, so the lone node never forms a cluster.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Peer_first_ordering_is_the_outage_gap_and_never_forms()
{
@@ -100,6 +104,8 @@ public sealed class SelfFirstSeedBootstrapTests : IAsyncLifetime
await TwoNodeClusterFixture.WaitForMembersUp(node, 1, TimeSpan.FromSeconds(30));
}
/// <summary>Verifies that a restarting node rejoins its live peer instead of forming its own cluster.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Restarting_node_rejoins_its_live_peer_instead_of_self_forming()
{
@@ -123,6 +129,8 @@ public sealed class SelfFirstSeedBootstrapTests : IAsyncLifetime
await TwoNodeClusterFixture.WaitForMembersUp(nodeA, 2, TimeSpan.FromSeconds(90));
}
/// <summary>Verifies that two nodes cold-starting together converge on a single cluster.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Both_nodes_cold_starting_together_converge_on_one_cluster()
{
@@ -140,8 +148,12 @@ public sealed class SelfFirstSeedBootstrapTests : IAsyncLifetime
await TwoNodeClusterFixture.WaitForMembersUp(nodeB, 2, TimeSpan.FromSeconds(60));
}
/// <summary>Prepares the fixture before the tests run.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public Task InitializeAsync() => Task.CompletedTask;
/// <summary>Releases the resources held by this fixture asynchronously.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task DisposeAsync()
{
foreach (var s in _systems)
@@ -5,7 +5,7 @@ using ZB.MOM.WW.ScadaBridge.Communication.ClusterState;
namespace ZB.MOM.WW.ScadaBridge.IntegrationTests.Cluster;
/// <summary>
/// Real-cluster proof for the central→site failover relay (Task 10). The unit tests in
/// Real-cluster proof for the central→site failover relay. The unit tests in
/// <c>SiteCommunicationActorTests</c> pin the routing and guard logic with the failover action
/// stubbed out; this pins the part they cannot — that the shared
/// <see cref="ClusterFailoverCoordinator"/> actually moves a SITE pair when scoped to the
@@ -17,6 +17,8 @@ namespace ZB.MOM.WW.ScadaBridge.IntegrationTests.Cluster;
/// </summary>
public sealed class SiteFailoverRelayTests
{
/// <summary>Verifies that failing over a site pair moves the oldest node and the survivor takes over.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Failing_over_a_site_pair_moves_the_oldest_and_the_survivor_takes_over()
{
@@ -35,6 +37,8 @@ public sealed class SiteFailoverRelayTests
Assert.True(ActiveNodeEvaluator.SelfIsOldestUp(Akka.Cluster.Cluster.Get(f.NodeB), "site-SiteA"));
}
/// <summary>Verifies that a site role scope matching no member is refused.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task A_site_role_scope_that_matches_no_member_is_refused()
{
@@ -20,11 +20,24 @@ namespace ZB.MOM.WW.ScadaBridge.IntegrationTests.Cluster;
/// </summary>
public sealed class TwoNodeClusterFixture : IAsyncDisposable
{
/// <summary>Gets the first cluster node, which is always started first and is therefore the oldest member.</summary>
public ActorSystem NodeA { get; private set; } = null!;
/// <summary>Gets the second cluster node, which joins after the first.</summary>
public ActorSystem NodeB { get; private set; } = null!;
/// <summary>Gets the remoting port used by the first node.</summary>
public int PortA { get; private set; }
/// <summary>Gets the remoting port used by the second node.</summary>
public int PortB { get; private set; }
/// <summary>Starts both nodes in order and waits until each sees two Up members.</summary>
/// <param name="role">The Akka role assigned to both nodes.</param>
/// <param name="stableAfter">How long membership must be stable before downing decisions; defaults to a scaled test value.</param>
/// <param name="portA">The remoting port for the first node, or null to pick a free port.</param>
/// <param name="portB">The remoting port for the second node, or null to pick a free port.</param>
/// <param name="heartbeatInterval">The failure-detector heartbeat interval; defaults to a scaled test value.</param>
/// <param name="failureDetectionThreshold">The failure-detector threshold; defaults to a scaled test value.</param>
/// <param name="strategy">The split-brain resolver strategy to configure.</param>
/// <returns>A task that yields the started two-node fixture.</returns>
public static async Task<TwoNodeClusterFixture> StartAsync(
string role = "Central", TimeSpan? stableAfter = null,
int? portA = null, int? portB = null,
@@ -45,6 +58,13 @@ public sealed class TwoNodeClusterFixture : IAsyncDisposable
/// <summary>Starts a node from production HOCON; used by StartAsync and by restart-scenarios.
/// <paramref name="strategy"/> defaults to the production posture (auto-down, decision
/// 2026-07-21); pass "keep-oldest" to exercise the legacy SBR path.</summary>
/// <param name="port">The remoting port for the node.</param>
/// <param name="role">The Akka role assigned to the node.</param>
/// <param name="stableAfter">How long membership must be stable before downing decisions; defaults to a scaled test value.</param>
/// <param name="heartbeatInterval">The failure-detector heartbeat interval; defaults to a scaled test value.</param>
/// <param name="failureDetectionThreshold">The failure-detector threshold; defaults to a scaled test value.</param>
/// <param name="strategy">The split-brain resolver strategy to configure.</param>
/// <returns>The started actor system.</returns>
public ActorSystem StartNode(int port, string role, TimeSpan? stableAfter = null,
TimeSpan? heartbeatInterval = null, TimeSpan? failureDetectionThreshold = null,
string strategy = "auto-down")
@@ -76,8 +96,15 @@ public sealed class TwoNodeClusterFixture : IAsyncDisposable
}
/// <summary>Hard-crash a node: abrupt terminate, no cluster leave.</summary>
/// <param name="node">The node to crash.</param>
/// <returns>A task that represents the asynchronous termination.</returns>
public static Task CrashNode(ActorSystem node) => node.Terminate();
/// <summary>Waits until the given system sees exactly the given number of Up members and none unreachable.</summary>
/// <param name="system">The actor system whose cluster view is polled.</param>
/// <param name="count">The expected number of Up members.</param>
/// <param name="timeout">The maximum time to wait.</param>
/// <returns>A task that completes when the expected membership is reached, or throws <see cref="TimeoutException"/> on timeout.</returns>
public static async Task WaitForMembersUp(ActorSystem system, int count, TimeSpan timeout)
{
var cluster = Akka.Cluster.Cluster.Get(system);
@@ -95,6 +122,11 @@ public sealed class TwoNodeClusterFixture : IAsyncDisposable
$"Unreachable: [{string.Join(", ", cluster.State.Unreachable)}]");
}
/// <summary>Waits until the given address is neither a member nor unreachable in the survivor's cluster view.</summary>
/// <param name="survivor">The actor system whose cluster view is polled.</param>
/// <param name="removed">The address expected to disappear from the view.</param>
/// <param name="timeout">The maximum time to wait.</param>
/// <returns>A task that completes when the member is gone, or throws <see cref="TimeoutException"/> on timeout.</returns>
public static async Task WaitForMemberRemoved(ActorSystem survivor, Address removed, TimeSpan timeout)
{
var cluster = Akka.Cluster.Cluster.Get(survivor);
@@ -109,6 +141,8 @@ public sealed class TwoNodeClusterFixture : IAsyncDisposable
throw new TimeoutException($"Member {removed} was never removed from {cluster.SelfAddress}'s view — SBR did not down it.");
}
/// <summary>Finds a free loopback TCP port.</summary>
/// <returns>A port number that was free when probed.</returns>
public static int GetFreeTcpPort()
{
var listener = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0);
@@ -118,6 +152,8 @@ public sealed class TwoNodeClusterFixture : IAsyncDisposable
return port;
}
/// <summary>Releases the resources used by this instance asynchronously.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async ValueTask DisposeAsync()
{
foreach (var sys in new[] { NodeA, NodeB })
@@ -4,6 +4,8 @@ namespace ZB.MOM.WW.ScadaBridge.IntegrationTests.Cluster;
public class TwoNodeClusterFixtureTests
{
/// <summary>Verifies that the fixture forms a two-node cluster in which node A is the oldest member.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Fixture_FormsTwoNodeCluster_NodeAIsOldest()
{
@@ -57,11 +57,14 @@ public sealed class DeployNotifyFetchTests
/// </summary>
private sealed class SqliteFetchDbContext : ScadaBridgeDbContext
{
/// <summary>Creates the SQLite-adapted context from the supplied options.</summary>
/// <param name="options">The EF Core options for the context.</param>
public SqliteFetchDbContext(DbContextOptions<ScadaBridgeDbContext> options)
: base(options, new EphemeralDataProtectionProvider())
{
}
/// <inheritdoc />
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
@@ -113,6 +116,7 @@ public sealed class DeployNotifyFetchTests
private readonly IHost _host;
private readonly DbContextOptions<ScadaBridgeDbContext> _dbOptions;
/// <summary>Gets the in-memory HTTP client that drives the config-fetch endpoint.</summary>
public HttpClient Client { get; }
/// <summary>Base URL the site presents to the fetcher (the test server's address).</summary>
@@ -131,6 +135,8 @@ public sealed class DeployNotifyFetchTests
BaseUrl = client.BaseAddress!.ToString();
}
/// <summary>Builds the harness: opens the in-memory database, starts the test server and creates the client.</summary>
/// <returns>The ready-to-use harness.</returns>
public static async Task<FetchHarness> CreateAsync()
{
var connection = new SqliteConnection("DataSource=:memory:");
@@ -177,6 +183,8 @@ public sealed class DeployNotifyFetchTests
/// and returns its generated id. The PendingDeployment → Instance FK is enforced by SQLite, so a
/// real row is required.
/// </summary>
/// <param name="uniqueName">The unique name of the instance to create.</param>
/// <returns>The generated instance id.</returns>
public async Task<int> SeedInstanceAsync(string uniqueName)
{
await using var ctx = new SqliteFetchDbContext(_dbOptions);
@@ -197,6 +205,8 @@ public sealed class DeployNotifyFetchTests
/// (<see cref="DeploymentManagerRepository.AddPendingDeploymentAsync"/> + SaveChanges), so
/// supersession of any prior row for the same instance is exercised exactly as in production.
/// </summary>
/// <param name="pending">The pending deployment to stage.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task StageAsync(PendingDeployment pending)
{
await using var ctx = new SqliteFetchDbContext(_dbOptions);
@@ -205,6 +215,7 @@ public sealed class DeployNotifyFetchTests
await repo.SaveChangesAsync();
}
/// <summary>Releases the resources held by this instance.</summary>
public void Dispose()
{
Client.Dispose();
@@ -239,6 +250,8 @@ public sealed class DeployNotifyFetchTests
// 1. Large config round-trips: the core proof that the 128 KB size limit is gone.
// ----------------------------------------------------------------------------------------
/// <summary>Verifies that a config larger than 128 KB is fetched with its exact bytes intact.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task FetchAsync_LargeConfigOver128Kb_RoundTripsExactBytes()
{
@@ -269,6 +282,8 @@ public sealed class DeployNotifyFetchTests
// Fetching A -> 404 (IsSuperseded); fetching B -> B's config.
// ----------------------------------------------------------------------------------------
/// <summary>Verifies that fetching a superseded deployment throws a 404 superseded error while the newest deployment is still served.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task FetchAsync_SupersededDeployment_Throws404SupersededAndServesNewest()
{
@@ -295,6 +310,8 @@ public sealed class DeployNotifyFetchTests
// 3. Wrong token: a live, non-expired row with a bad token -> 401 (NOT 404).
// ----------------------------------------------------------------------------------------
/// <summary>Verifies that fetching with a wrong token throws a 401 error that is not reported as superseded.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task FetchAsync_WrongToken_Throws401NotSuperseded()
{
@@ -318,6 +335,8 @@ public sealed class DeployNotifyFetchTests
// even with the correct token.
// ----------------------------------------------------------------------------------------
/// <summary>Verifies that fetching an expired row throws a 404 superseded error even with the correct token.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task FetchAsync_ExpiredRow_Throws404SupersededEvenWithCorrectToken()
{
@@ -6,12 +6,14 @@ using ZB.MOM.WW.ScadaBridge.TestSupport;
namespace ZB.MOM.WW.ScadaBridge.IntegrationTests;
/// <summary>
/// WP-3 (Phase 8): Dual-node failure recovery.
/// Dual-node failure recovery.
/// Both nodes down, first up forms cluster, rebuilds from persistent storage.
/// Tests for both central and site topologies.
/// </summary>
public class DualNodeRecoveryTests
{
/// <summary>Verifies that after both site nodes go down the first node rebuilds its state from SQLite.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Trait("Category", "Integration")]
[Fact]
public async Task SiteTopology_BothNodesDown_FirstNodeRebuildsFromSQLite()
@@ -83,6 +85,8 @@ public class DualNodeRecoveryTests
}
}
/// <summary>Verifies that parked messages are preserved across a dual crash of a site pair.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Trait("Category", "Integration")]
[Fact]
public async Task SiteTopology_DualCrash_ParkedMessagesPreserved()
@@ -154,6 +158,7 @@ public class DualNodeRecoveryTests
}
}
/// <summary>Verifies that after both central nodes go down the first node forms a single-node cluster.</summary>
[Trait("Category", "Integration")]
[Fact]
public void CentralTopology_BothNodesDown_FirstNodeFormsSingleNodeCluster()
@@ -185,6 +190,8 @@ public class DualNodeRecoveryTests
Assert.Equal(DeploymentStatus.InProgress, statuses[1].Status);
}
/// <summary>Verifies that initializing the SQLite storage repeatedly is idempotent and safe during recovery.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Trait("Category", "Integration")]
[Fact]
public async Task SQLiteStorage_InitializeIdempotent_SafeOnRecovery()
@@ -51,6 +51,8 @@ public class GrpcStreamBatchingIntegrationTests(ITestOutputHelper output) : Test
// ── Round trip: batched frames deliver every event, in order ────────────────
/// <summary>Verifies that a negotiated stream round-trips every event through the wire in order.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task NegotiatedStream_RoundTripsEveryEventThroughTheWire_InOrder()
{
@@ -95,6 +97,8 @@ public class GrpcStreamBatchingIntegrationTests(ITestOutputHelper output) : Test
// ── Latency cost of the default window ─────────────────────────────────────
/// <summary>Verifies that the default batching window keeps per-event latency far below the threshold.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DefaultWindow_KeepsPerEventLatencyFarBelowTheThreshold()
{
@@ -163,6 +167,8 @@ public class GrpcStreamBatchingIntegrationTests(ITestOutputHelper output) : Test
// ── Version skew: OLD central ↔ NEW site ───────────────────────────────────
/// <summary>Verifies that an old central talking to a new site never receives a batch frame.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task OldCentral_AgainstNewSite_NeverReceivesABatchFrame()
{
@@ -189,7 +195,7 @@ public class GrpcStreamBatchingIntegrationTests(ITestOutputHelper output) : Test
var wire = SnapshotThroughTheWire(frames);
// One event per frame, and — checked at the byte level, since that is what the
// old peer's parser sees — never the field-4 batch tag.
// old peer's parser sees — never the field 4 batch tag.
Assert.Equal(total, wire.Count);
Assert.All(wire, f =>
Assert.Equal(SiteStreamEvent.EventOneofCase.AttributeChanged, f.EventCase));
@@ -198,6 +204,7 @@ public class GrpcStreamBatchingIntegrationTests(ITestOutputHelper output) : Test
GC.KeepAlive(server);
}
/// <summary>Verifies that the batch frame rides field 4, which a pre-batching parser would drop silently.</summary>
[Fact]
public void BatchFrameRidesFieldFour_WhichAPreBatchingParserWouldDropSilently()
{
@@ -232,6 +239,8 @@ public class GrpcStreamBatchingIntegrationTests(ITestOutputHelper output) : Test
// ── Version skew: NEW central ↔ OLD site ───────────────────────────────────
/// <summary>Verifies that a new central talking to an old site still receives every event.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task NewCentral_AgainstOldSite_StillReceivesEveryEvent()
{
@@ -30,6 +30,7 @@ public class GrpcStreamIntegrationTests : TestKit
/// events flow through Channel to gRPC response stream.
/// Validates attribute value changes arrive with correct protobuf mapping.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Pipeline_AttributeValueChanged_FlowsToResponseStream()
{
@@ -105,6 +106,7 @@ public class GrpcStreamIntegrationTests : TestKit
/// <summary>
/// End-to-end pipeline test for alarm state changes.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Pipeline_AlarmStateChanged_FlowsToResponseStream()
{
@@ -164,6 +166,7 @@ public class GrpcStreamIntegrationTests : TestKit
/// <summary>
/// Verifies that mixed event types (attribute + alarm) flow through the same stream.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Pipeline_MixedEvents_FlowInOrder()
{
@@ -226,6 +229,7 @@ public class GrpcStreamIntegrationTests : TestKit
/// Verifies that when a stream is cancelled, the subscriber is cleaned up
/// and the active stream count returns to zero.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Pipeline_Cancellation_CleansUpRelayActorAndSubscription()
{
@@ -265,6 +269,7 @@ public class GrpcStreamIntegrationTests : TestKit
/// Verifies that a duplicate correlation ID cancels the first stream and
/// the second stream continues to receive events.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Pipeline_DuplicateCorrelationId_ReplacesStream()
{
@@ -16,7 +16,7 @@ using ZB.MOM.WW.ScadaBridge.SiteRuntime.Streaming;
namespace ZB.MOM.WW.ScadaBridge.IntegrationTests.Grpc;
/// <summary>
/// End-to-end trace for the aggregated live alarm stream (plan #10 deferred item, Task 7).
/// End-to-end trace for the aggregated live alarm stream.
///
/// Every per-task unit test (T1–T6) exercises exactly one layer with its neighbour mocked:
/// T1 the site broadcast filter (subscriber = TestKit probe, domain events), T2/the existing
@@ -41,7 +41,7 @@ namespace ZB.MOM.WW.ScadaBridge.IntegrationTests.Grpc;
/// for MULTIPLE instances (no per-instance filter); and a snapshot-seed row and a live delta
/// for the same native alarm — both mapped through the real pipe — collapse onto ONE cache row.
///
/// Full gRPC-over-HTTP/2 remains a manual docker-cluster smoke (plan §4 Task 7).
/// Full gRPC-over-HTTP/2 remains a manual docker-cluster smoke (plan §4).
/// </summary>
public class SiteAlarmStreamEndToEndTests : TestKit
{
@@ -50,6 +50,8 @@ public class SiteAlarmStreamEndToEndTests : TestKit
// ── The full site→proto→client trace ────────────────────────────────────────
/// <summary>Verifies that a site-wide alarm traverses the manager, relay and client, preserving identity and enrichment while dropping attributes and placeholders.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SiteWideAlarm_TraversesManagerToRelayToClient_PreservingIdentityAndEnrichment_DroppingAttributesAndPlaceholders()
{
@@ -173,6 +175,8 @@ public class SiteAlarmStreamEndToEndTests : TestKit
// ── Snapshot-seed vs live-delta identity parity, through the real pipe ────────
/// <summary>Verifies that a seed row and a live delta for the same native alarm collapse onto one cache row.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RealPipeMapped_SeedRow_And_LiveDelta_ForSameNativeAlarm_CollapseOntoOneCacheRow()
{
@@ -226,7 +230,7 @@ public class SiteAlarmStreamEndToEndTests : TestKit
sink.Publish, factory, "http://a:5100", "http://b:5100", TimeSpan.FromMinutes(10),
TimeSpan.Zero, TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(60),
// Props.Create is an expression tree, which may not omit optional arguments
// (CS0854) — jitter and the P3-4 open-attempt deadline are passed explicitly.
// (CS0854) — jitter and the open-attempt deadline are passed explicitly.
0.0, SiteAlarmAggregatorActor.DefaultOpenAttemptTimeout)));
await WaitForConditionAsync(() => sink.Latest is { Count: 1 }); // seed applied
@@ -268,10 +272,14 @@ public class SiteAlarmStreamEndToEndTests : TestKit
{
private readonly object _lock = new();
private IReadOnlyList<AlarmStateChanged>? _latest;
/// <summary>Records the latest published snapshot.</summary>
/// <param name="snapshot">The alarm snapshot published to the sink.</param>
/// <param name="streamLive">Whether the live stream is connected (ignored).</param>
public void Publish(IReadOnlyList<AlarmStateChanged> snapshot, bool streamLive)
{
lock (_lock) { _latest = snapshot; }
}
/// <summary>Gets the most recently published snapshot, or null if none has been published.</summary>
public IReadOnlyList<AlarmStateChanged>? Latest
{
get { lock (_lock) { return _latest; } }
@@ -281,6 +289,7 @@ public class SiteAlarmStreamEndToEndTests : TestKit
/// <summary>Aggregator transport double: its live stream simply stays open until cancelled.</summary>
private sealed class NoopSiteStreamClient : SiteStreamGrpcClient
{
/// <inheritdoc />
public override Task SubscribeSiteAsync(
string correlationId, Action<AlarmStateChanged> onAlarmEvent, Action<Exception> onError,
Action onCompleted, CancellationToken ct, Action? onConnected = null)
@@ -291,14 +300,18 @@ public class SiteAlarmStreamEndToEndTests : TestKit
return tcs.Task;
}
/// <inheritdoc />
public override void Unsubscribe(string correlationId) { }
}
private sealed class NoopSiteStreamClientFactory : SiteStreamGrpcClientFactory
{
private readonly NoopSiteStreamClient _client = new();
/// <summary>Initializes the factory with a null logger factory.</summary>
public NoopSiteStreamClientFactory() : base(NullLoggerFactory.Instance) { }
/// <inheritdoc />
public override SiteStreamGrpcClient GetOrCreate(string siteIdentifier, string grpcEndpoint) => _client;
/// <inheritdoc />
public override SiteStreamGrpcClient? TryGet(string siteIdentifier, string grpcEndpoint) => _client;
}
}
@@ -7,7 +7,7 @@ using ZB.MOM.WW.ScadaBridge.InboundAPI;
namespace ZB.MOM.WW.ScadaBridge.IntegrationTests;
/// <summary>
/// WP-14: End-to-end integration tests for Phase 7 integration surfaces.
/// End-to-end integration tests for Phase 7 integration surfaces.
/// </summary>
public class IntegrationSurfaceTests
{
@@ -17,6 +17,7 @@ public class IntegrationSurfaceTests
// ZB.MOM.WW.Auth.ApiKeys verifier (Bearer sbk_<keyId>_<secret>); its coverage
// lives in the InboundAPI endpoint + Auth-library tests.
/// <summary>Verifies that inbound API parameter validation handles the extended types and reports the right error codes.</summary>
[Fact]
public void InboundAPI_ParameterValidation_ExtendedTypes()
{
@@ -46,10 +47,11 @@ public class IntegrationSurfaceTests
// ── External System: error classification ──
/// <summary>Verifies that external system HTTP errors are classified as transient or permanent.</summary>
[Fact]
public void ExternalSystem_ErrorClassification_TransientVsPermanent()
{
// WP-8: Verify the full classification spectrum
// Verify the full classification spectrum
Assert.True(ExternalSystemGateway.ErrorClassifier.IsTransient(HttpStatusCode.InternalServerError));
Assert.True(ExternalSystemGateway.ErrorClassifier.IsTransient(HttpStatusCode.ServiceUnavailable));
Assert.True(ExternalSystemGateway.ErrorClassifier.IsTransient(HttpStatusCode.RequestTimeout));
@@ -62,7 +64,7 @@ public class IntegrationSurfaceTests
}
// ── Notification: mock SMTP delivery ──
// NS-019: the site-shaped NotificationDeliveryService that this case exercised
// The site-shaped NotificationDeliveryService that this case exercised
// was removed when sites stopped delivering notifications. The central SMTP
// delivery path is now covered end-to-end by
// ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests.Delivery.EmailNotificationDeliveryAdapterTests;
@@ -70,6 +72,8 @@ public class IntegrationSurfaceTests
// ── Script Context: integration API wiring ──
/// <summary>Verifies that the script context routes external system calls to the configured client.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ScriptContext_ExternalSystem_Call_Wired()
{
@@ -86,6 +90,8 @@ public class IntegrationSurfaceTests
Assert.Equal("{\"value\":1}", result.ResponseJson);
}
/// <summary>Verifies that the script context routes notification sends to the configured service.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ScriptContext_Notify_Send_Wired()
{
@@ -111,6 +117,8 @@ public class IntegrationSurfaceTests
Assert.NotNull(buffered);
}
/// <summary>Verifies that an external system call throws when no client is configured.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ScriptContext_ExternalSystem_NoClient_Throws()
{
@@ -120,6 +128,8 @@ public class IntegrationSurfaceTests
() => context.ExternalSystem.Call("api", "method"));
}
/// <summary>Verifies that a database call throws when no gateway is configured.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ScriptContext_Database_NoGateway_Throws()
{
@@ -129,6 +139,8 @@ public class IntegrationSurfaceTests
() => context.Database.Connection("db"));
}
/// <summary>Verifies that a notification send throws when no service is configured.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ScriptContext_Notify_NoService_Throws()
{
@@ -59,6 +59,8 @@ public sealed class LocalDbConfigConvergenceTests : LocalDbSitePairHarness
// ---- scenarios --------------------------------------------------------------------
/// <summary>Verifies that a node with newer local rows keeps them when a peer snapshot arrives.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ANodeWithNewerLocalRows_KeepsThem_WhenAPeerSnapshotArrives()
{
@@ -110,13 +112,15 @@ public sealed class LocalDbConfigConvergenceTests : LocalDbSitePairHarness
"both nodes to converge on the newer config for the contended instance");
}
/// <summary>Verifies that a config deployed on node A converges to node B through replication alone, without any fetch.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ConfigDeployedOnA_ConvergesToB_WithoutAnyFetch()
{
// Was: SiteReplicationActorTests #1-#5 (notify-and-fetch — the standby is told a
// Was: the SiteReplicationActor tests (notify-and-fetch — the standby is told a
// deploy happened, then HTTP-fetches the config itself, with retries and a
// superseded-404 path). Under CDC the config row simply replicates, so that whole
// exchange is deleted at Task 15.
// superseded-config 404 path). Under CDC the config row simply replicates, so that whole
// exchange is deleted.
//
// The plan called for asserting a fetcher test double records zero invocations. That
// would be THEATRE here and is deliberately not done: this harness has no actor
@@ -126,7 +130,7 @@ public sealed class LocalDbConfigConvergenceTests : LocalDbSitePairHarness
//
// What this test honestly proves is the positive half: the config reaches node B
// through replication ALONE, in a process where fetching is not merely unused but
// absent. The negative half — that no fetch path survives — is proved by Task 15
// absent. The negative half — that no fetch path survives — is proved by
// deleting the code and the build still passing, which is a stronger check than any
// runtime counter.
//
@@ -140,6 +144,8 @@ public sealed class LocalDbConfigConvergenceTests : LocalDbSitePairHarness
"the deployed config to reach node B over replication alone");
}
/// <summary>Verifies that redeploying an instance converges both nodes to the new revision.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RedeployingAnInstance_ConvergesToTheNewRevision_OnBothNodes()
{
@@ -160,6 +166,8 @@ public sealed class LocalDbConfigConvergenceTests : LocalDbSitePairHarness
"both nodes to converge on the redeployed revision");
}
/// <summary>Verifies that a config deployed on node B converges to node A.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ConfigDeployedOnB_ConvergesToA()
{
@@ -66,6 +66,8 @@ public sealed class LocalDbPhase2ConvergenceTests : LocalDbSitePairHarness
// ---- scenarios --------------------------------------------------------------------
/// <summary>Verifies that a deployed configuration row converges to node B column for column.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeployedConfigRow_ConvergesToB_ColumnForColumn()
{
@@ -85,6 +87,8 @@ public sealed class LocalDbPhase2ConvergenceTests : LocalDbSitePairHarness
"node B's deployed_configurations row to match node A's in EVERY column");
}
/// <summary>Verifies that removing an instance converges deletions across all three cascade tables.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RemovingAnInstance_ConvergesAllThreeCascadeTables()
{
@@ -132,11 +136,13 @@ public sealed class LocalDbPhase2ConvergenceTests : LocalDbSitePairHarness
Assert.Equal(1, await CountAsync(B, "native_alarm_state", "inst-survivor"));
}
/// <summary>Verifies that a native alarm burst converges to the peer and the oplog drains afterwards.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ANativeAlarmBurst_Converges_AndTheOplogDrains()
{
// native_alarm_state is the highest-rate replicated table (Task 1 found it bounded by
// per-SourceReference coalescing on a 100 ms flush, correcting the plan's D4). This
// native_alarm_state is the highest-rate replicated table (it is bounded by
// per-SourceReference coalescing on a 100 ms flush). This
// asserts the burst converges AND that the oplog goes back to empty afterwards —
// convergence alone would still pass if entries replicated but were never acked, and
// an oplog that only grows eventually trips the caps and forces a snapshot resync.
@@ -159,6 +165,8 @@ public sealed class LocalDbPhase2ConvergenceTests : LocalDbSitePairHarness
"node A's oplog to drain once node B acknowledges the burst");
}
/// <summary>Verifies that rows written on node B while its listener is down survive the rejoin.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RowsWrittenOnBWhileItsListenerIsDown_SurviveTheRejoin()
{
@@ -55,6 +55,8 @@ public sealed class LocalDbSitePairConvergenceTests : LocalDbSitePairHarness
// ---- scenarios --------------------------------------------------------------------
/// <summary>Verifies that a tracking row written on node A becomes readable on node B.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task TrackingRow_WrittenOnA_BecomesReadableOnB()
{
@@ -65,6 +67,8 @@ public sealed class LocalDbSitePairConvergenceTests : LocalDbSitePairHarness
"the tracking row written on node A to appear on node B");
}
/// <summary>Verifies that a tracking row written on node B becomes readable on node A.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task TrackingRow_WrittenOnB_BecomesReadableOnA()
{
@@ -77,6 +81,8 @@ public sealed class LocalDbSitePairConvergenceTests : LocalDbSitePairHarness
"the tracking row written on node B to appear on node A");
}
/// <summary>Verifies that the same operation updated on both nodes converges to a single winning value.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SameOperation_UpdatedOnBothNodes_ConvergesToOneWinner()
{
@@ -104,6 +110,8 @@ public sealed class LocalDbSitePairConvergenceTests : LocalDbSitePairHarness
Assert.Contains(winner, new[] { "Delivered", "Parked" });
}
/// <summary>Verifies that events logged on both nodes converge to their union with no id collisions.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task EventsLoggedOnBothNodes_ConvergeToTheUnion_WithNoIdCollisions()
{
@@ -125,6 +133,8 @@ public sealed class LocalDbSitePairConvergenceTests : LocalDbSitePairHarness
"both nodes to hold the union of all 10 events");
}
/// <summary>Verifies that a peer that goes offline and rejoins catches up on everything it missed.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PeerOffline_ThenRejoins_CatchesUpOnEverythingItMissed()
{
@@ -70,6 +70,8 @@ public abstract class LocalDbSitePairHarness : IAsyncLifetime
/// <summary>Node B — the passive node, which listens.</summary>
protected ILocalDb B => _dbProviderB.GetRequiredService<ILocalDb>();
/// <summary>Prepares the test class before the tests run.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task InitializeAsync()
{
_dbProviderA = BuildDatabaseProvider(_pathA, "node-a");
@@ -83,6 +85,8 @@ public abstract class LocalDbSitePairHarness : IAsyncLifetime
await StartInitiatorAsync();
}
/// <summary>Releases the resources held by this test class asynchronously.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task DisposeAsync()
{
await StopHostAsync(_initiatorHost);
@@ -179,6 +183,7 @@ public abstract class LocalDbSitePairHarness : IAsyncLifetime
}
/// <summary>Starts node B, the passive listener.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
protected async Task StartPassiveAsync()
{
var config = ReplicationConfig(peerAddress: null);
@@ -208,6 +213,7 @@ public abstract class LocalDbSitePairHarness : IAsyncLifetime
}
/// <summary>Starts node A, which dials the passive node.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
protected async Task StartInitiatorAsync()
{
var config = ReplicationConfig(PassiveAddress());
@@ -223,6 +229,7 @@ public abstract class LocalDbSitePairHarness : IAsyncLifetime
}
/// <summary>Takes node B's listener down, leaving its database intact and writable.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
protected async Task StopPassiveAsync()
{
await StopHostAsync(_serverHost);
@@ -233,6 +240,7 @@ public abstract class LocalDbSitePairHarness : IAsyncLifetime
/// Brings node B back on a NEW loopback port and re-dials from A. The initiator's channel
/// factory re-reads the peer address on each reconnect, so this is a genuine rejoin.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
protected async Task RestartPairAsync()
{
await StartPassiveAsync();
@@ -252,6 +260,9 @@ public abstract class LocalDbSitePairHarness : IAsyncLifetime
}
/// <summary>Polls <paramref name="condition"/> until true or the deadline passes.</summary>
/// <param name="condition">The asynchronous condition to poll.</param>
/// <param name="because">The failure message used if the condition never becomes true.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
protected static async Task WaitUntilAsync(Func<Task<bool>> condition, string because)
{
var deadline = DateTime.UtcNow + ConvergeTimeout;
@@ -10,7 +10,7 @@ namespace ZB.MOM.WW.ScadaBridge.IntegrationTests;
/// <para>
/// These are <b>specifications, written before the deletion they justify</b>. The bespoke
/// <c>ReplicationService</c> (an explicit Add/Remove/Park/Requeue operation stream over Akka)
/// is deleted at Task 14 and replaced by LocalDb's trigger-based change capture. Each test
/// was deleted and replaced by LocalDb's trigger-based change capture. Each test
/// here is one behaviour the old mechanism provided, restated as an outcome the replacement
/// must still deliver — so the cutover has something to be judged against other than "it
/// compiles".
@@ -83,6 +83,8 @@ public sealed class LocalDbStoreAndForwardConvergenceTests : LocalDbSitePairHarn
// ---- scenarios --------------------------------------------------------------------
/// <summary>Verifies that a buffered message materialises on the peer.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task BufferedMessage_MaterialisesOnThePeer()
{
@@ -94,6 +96,8 @@ public sealed class LocalDbStoreAndForwardConvergenceTests : LocalDbSitePairHarn
"a message buffered on node A to appear on node B");
}
/// <summary>Verifies that a delivered message disappears from the peer.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task DeliveredMessage_DisappearsFromThePeer()
{
@@ -110,6 +114,8 @@ public sealed class LocalDbStoreAndForwardConvergenceTests : LocalDbSitePairHarn
"the delivered message to be gone from node B");
}
/// <summary>Verifies that a parked message shows as parked on the peer.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ParkedMessage_ShowsAsParkedOnThePeer()
{
@@ -124,6 +130,8 @@ public sealed class LocalDbStoreAndForwardConvergenceTests : LocalDbSitePairHarn
"the parked status to reach node B");
}
/// <summary>Verifies that a requeued message resets its status and retry count on the peer.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RequeuedMessage_ResetsStatusAndRetryCountOnThePeer()
{
@@ -145,6 +153,8 @@ public sealed class LocalDbStoreAndForwardConvergenceTests : LocalDbSitePairHarn
"node B to show the requeued message as Pending with retry_count 0");
}
/// <summary>Verifies that a message that is added and then removed never converges to present on the peer.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task MessageAddedThenRemoved_NeverConvergesToPresent()
{
@@ -182,6 +192,8 @@ public sealed class LocalDbStoreAndForwardConvergenceTests : LocalDbSitePairHarn
Assert.True(await ExistsAsync(B, "msg-ordering-control"));
}
/// <summary>Verifies that a message buffered twice converges to the newer state on the peer.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SameMessage_BufferedTwice_ConvergesToTheNewerState()
{
@@ -199,6 +211,8 @@ public sealed class LocalDbStoreAndForwardConvergenceTests : LocalDbSitePairHarn
"the newer state to win on node B");
}
/// <summary>Verifies that a park arriving without its preceding add still materialises the row on the peer.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ParkArrivingWithoutItsAdd_StillMaterialisesTheRow()
{
@@ -22,7 +22,7 @@ using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Delivery;
namespace ZB.MOM.WW.ScadaBridge.IntegrationTests;
/// <summary>
/// Task 25: end-to-end integration test for the central Notification Outbox flow. Exercises
/// End-to-end integration test for the central Notification Outbox flow. Exercises
/// the full ingest -> dispatch -> status pipeline against a real <see cref="NotificationOutboxActor"/>,
/// a real <see cref="NotificationOutboxRepository"/> over a real (SQLite in-memory) database, and
/// a stub <see cref="INotificationDeliveryAdapter"/>. Two scenarios are covered: a successful
@@ -39,11 +39,14 @@ public class NotificationOutboxFlowTests : TestKit
/// </summary>
private sealed class SqliteOutboxDbContext : ScadaBridgeDbContext
{
/// <summary>Initializes the context over the given SQLite options with an ephemeral data protection provider.</summary>
/// <param name="options">The EF Core options configured for the SQLite connection.</param>
public SqliteOutboxDbContext(DbContextOptions<ScadaBridgeDbContext> options)
: base(options, new EphemeralDataProtectionProvider())
{
}
/// <inheritdoc />
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
@@ -81,10 +84,14 @@ public class NotificationOutboxFlowTests : TestKit
{
private readonly DeliveryOutcome _outcome;
/// <summary>Initializes the adapter with the outcome every delivery returns.</summary>
/// <param name="outcome">The fixed delivery outcome to return.</param>
public StubEmailAdapter(DeliveryOutcome outcome) => _outcome = outcome;
/// <inheritdoc />
public NotificationType Type => NotificationType.Email;
/// <inheritdoc />
public Task<DeliveryOutcome> DeliverAsync(
Notification notification, CancellationToken cancellationToken = default)
=> Task.FromResult(_outcome);
@@ -97,15 +104,21 @@ public class NotificationOutboxFlowTests : TestKit
/// </summary>
private sealed class OutboxHarness : IDisposable
{
/// <summary>Gets the kept-open SQLite connection.</summary>
public SqliteConnection Connection { get; }
/// <summary>Gets the composed service provider.</summary>
public IServiceProvider Services { get; }
/// <summary>Initializes the harness with the connection and service provider it owns.</summary>
/// <param name="connection">The kept-open SQLite connection.</param>
/// <param name="services">The composed service provider.</param>
public OutboxHarness(SqliteConnection connection, IServiceProvider services)
{
Connection = connection;
Services = services;
}
/// <summary>Releases the resources held by this test class.</summary>
public void Dispose()
{
(Services as IDisposable)?.Dispose();
@@ -182,11 +195,12 @@ public class NotificationOutboxFlowTests : TestKit
/// <summary>
/// Test-only no-op <see cref="ICentralAuditWriter"/>. The integration tests
/// in this file pre-date M4 Bundle B's audit-writer injection; they do not
/// in this file pre-date the audit-writer injection; they do not
/// assert on emission, just need a non-null collaborator.
/// </summary>
private sealed class NoOpCentralAuditWriter : ICentralAuditWriter
{
/// <inheritdoc />
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) => Task.CompletedTask;
}
@@ -210,6 +224,7 @@ public class NotificationOutboxFlowTests : TestKit
return await repository.GetByIdAsync(notificationId);
}
/// <summary>Verifies that a successfully dispatched notification reaches the Delivered status.</summary>
[Fact]
public void Submit_DispatchedSuccessfully_ReachesDeliveredStatus()
{
@@ -239,6 +254,7 @@ public class NotificationOutboxFlowTests : TestKit
interval: TimeSpan.FromMilliseconds(100));
}
/// <summary>Verifies that a permanently failed notification reaches Parked and a retry returns it to Pending.</summary>
[Fact]
public void Submit_PermanentlyFailed_ReachesParked_ThenRetryReturnsToPending()
{
@@ -8,7 +8,7 @@ using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
namespace ZB.MOM.WW.ScadaBridge.IntegrationTests;
/// <summary>
/// WP-8 (Phase 8): Observability validation.
/// Observability validation.
/// Verifies structured logs contain SiteId/NodeHostname/NodeRole,
/// correlation IDs flow through request chains, and health dashboard shows all metric types.
/// </summary>
@@ -16,11 +16,14 @@ public class ObservabilityTests : IClassFixture<ScadaBridgeWebApplicationFactory
{
private readonly ScadaBridgeWebApplicationFactory _factory;
/// <summary>Initializes the test class with the shared web application factory.</summary>
/// <param name="factory">The shared web application factory.</param>
public ObservabilityTests(ScadaBridgeWebApplicationFactory factory)
{
_factory = factory;
}
/// <summary>Verifies that the Serilog output template includes the required node fields.</summary>
[Fact]
public void StructuredLog_SerilogTemplate_IncludesRequiredFields()
{
@@ -33,6 +36,7 @@ public class ObservabilityTests : IClassFixture<ScadaBridgeWebApplicationFactory
Assert.Contains("{Level", template);
}
/// <summary>Verifies that the Serilog enrichment is configured with the site id.</summary>
[Fact]
public void SerilogEnrichment_SiteId_Configured()
{
@@ -48,6 +52,7 @@ public class ObservabilityTests : IClassFixture<ScadaBridgeWebApplicationFactory
}
}
/// <summary>Verifies that every message contract carries a correlation id.</summary>
[Fact]
public void CorrelationId_MessageContracts_AllHaveCorrelationId()
{
@@ -75,6 +80,7 @@ public class ObservabilityTests : IClassFixture<ScadaBridgeWebApplicationFactory
Assert.Equal("cmd-456", disableCmd.CommandId);
}
/// <summary>Verifies that every metric type is represented in the site health report.</summary>
[Fact]
public void HealthDashboard_AllMetricTypes_RepresentedInReport()
{
@@ -128,6 +134,7 @@ public class ObservabilityTests : IClassFixture<ScadaBridgeWebApplicationFactory
Assert.Equal(5, report.DeadLetterCount);
}
/// <summary>Verifies that a registered site is marked online by the health aggregator.</summary>
[Fact]
public void HealthAggregator_SiteRegistration_MarkedOnline()
{
@@ -162,6 +169,7 @@ public class ObservabilityTests : IClassFixture<ScadaBridgeWebApplicationFactory
Assert.Equal(3, state.LatestReport!.ScriptErrorCount);
}
/// <summary>Verifies that health report sequence numbers increase monotonically.</summary>
[Fact]
public void HealthReport_SequenceNumbers_Monotonic()
{
@@ -3,17 +3,21 @@ using System.Net;
namespace ZB.MOM.WW.ScadaBridge.IntegrationTests;
/// <summary>
/// WP-22: Readiness gating — /health/ready endpoint returns status code.
/// Readiness gating — /health/ready endpoint returns status code.
/// </summary>
public class ReadinessTests : IClassFixture<ScadaBridgeWebApplicationFactory>
{
private readonly ScadaBridgeWebApplicationFactory _factory;
/// <summary>Initializes the test class with the web application factory.</summary>
/// <param name="factory">The factory.</param>
public ReadinessTests(ScadaBridgeWebApplicationFactory factory)
{
_factory = factory;
}
/// <summary>Verifies that the health ready endpoint returns a success status code.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task HealthReady_ReturnsSuccessStatusCode()
{
@@ -8,11 +8,12 @@ using ZB.MOM.WW.ScadaBridge.TestSupport;
namespace ZB.MOM.WW.ScadaBridge.IntegrationTests;
/// <summary>
/// WP-7 (Phase 8): Recovery drill test scaffolds.
/// Recovery drill test scaffolds.
/// Mid-deploy failover, communication drops, and site restart with persisted configs.
/// </summary>
public class RecoveryDrillTests
{
/// <summary>Verifies that a site state query after a mid-deploy failover is followed by a successful redeploy.</summary>
[Trait("Category", "Integration")]
[Fact]
public void MidDeployFailover_SiteStateQuery_ThenRedeploy()
@@ -53,6 +54,8 @@ public class RecoveryDrillTests
Assert.Equal(DeploymentStatus.Success, completedStatus.Status);
}
/// <summary>Verifies that a communication drop during artifact deployment buffers the work for retry.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Trait("Category", "Integration")]
[Fact]
public async Task CommunicationDrop_DuringArtifactDeployment_BuffersForRetry()
@@ -113,6 +116,8 @@ public class RecoveryDrillTests
}
}
/// <summary>Verifies that a site restart with persisted configurations rebuilds its state from SQLite.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Trait("Category", "Integration")]
[Fact]
public async Task SiteRestart_WithPersistedConfigs_RebuildFromSQLite()
@@ -172,6 +177,7 @@ public class RecoveryDrillTests
}
}
/// <summary>Verifies that redeploying the same revision hash is a no-op.</summary>
[Fact]
public void DeploymentIdempotency_SameRevisionHash_NoOp()
{
@@ -191,6 +197,7 @@ public class RecoveryDrillTests
Assert.Equal(cmd1.InstanceUniqueName, cmd2.InstanceUniqueName);
}
/// <summary>Verifies that the flattened configuration snapshot contains the revision hash.</summary>
[Fact]
public void FlattenedConfigSnapshot_ContainsRevisionHash()
{
@@ -22,6 +22,7 @@ public class ScadaBridgeWebApplicationFactory : WebApplicationFactory<Program>
/// </summary>
private readonly Dictionary<string, string?> _previousEnvVars = new();
/// <summary>Initializes the factory and sets the environment variables that configure a Central node for tests.</summary>
public ScadaBridgeWebApplicationFactory()
{
// The initial ConfigurationBuilder in Program.cs reads env vars with AddEnvironmentVariables().
@@ -30,7 +31,7 @@ public class ScadaBridgeWebApplicationFactory : WebApplicationFactory<Program>
{
["DOTNET_ENVIRONMENT"] = "Development",
["ScadaBridge__Node__Role"] = "Central",
// NodeName is eagerly validated at boot (arch-review 08 round 2 NF4 / plan R2-08 T7):
// NodeName is eagerly validated at boot:
// an empty node name stamps the SourceNode audit column NULL, so the validator now
// fails host boot without it. The integration harness must supply one.
["ScadaBridge__Node__NodeName"] = "central-a",
@@ -46,7 +47,7 @@ public class ScadaBridgeWebApplicationFactory : WebApplicationFactory<Program>
// enforces a >=16-char floor; it backs the peppered-HMAC verifier). Supply a fixed
// test pepper so host boot passes validation in the test environment.
["ScadaBridge__InboundApi__ApiKeyPepper"] = "integration-test-api-key-pepper-0123456789",
// Task 1.4: LDAP settings nest under Security:Ldap (shared LdapOptions) and use
// LDAP settings nest under Security:Ldap (shared LdapOptions) and use
// the renamed keys (Transport replaces LdapUseTls; None == plaintext for the
// GLAuth dev directory, paired with AllowInsecure=true).
["ScadaBridge__Security__Ldap__Server"] = "localhost",
@@ -59,7 +60,7 @@ public class ScadaBridgeWebApplicationFactory : WebApplicationFactory<Program>
// succeeds but its searches return `50 Insufficient access`, i.e. auth reads as
// "service unavailable"). Matches the local redundant pair
// (scadaproj/infra/glauth-redundant), whose serviceaccount password is the
// well-known dev value — the shared 10.100.0.35 instance's is rotated (SEC-36).
// well-known dev value — the shared 10.100.0.35 instance's has been rotated.
["ScadaBridge__Security__Ldap__ServiceAccountDn"] = "cn=serviceaccount,dc=zb,dc=local",
["ScadaBridge__Security__Ldap__ServiceAccountPassword"] = "serviceaccount123",
};
@@ -71,6 +72,7 @@ public class ScadaBridgeWebApplicationFactory : WebApplicationFactory<Program>
}
}
/// <inheritdoc />
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseEnvironment("Development");
@@ -105,6 +107,7 @@ public class ScadaBridgeWebApplicationFactory : WebApplicationFactory<Program>
});
}
/// <inheritdoc />
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
@@ -6,7 +6,7 @@ using ZB.MOM.WW.ScadaBridge.Security;
namespace ZB.MOM.WW.ScadaBridge.IntegrationTests;
/// <summary>
/// WP-5 (Phase 8): Security hardening tests.
/// Security hardening tests.
/// Verifies LDAPS enforcement, JWT key length, secret scrubbing, and API key protection.
/// </summary>
public class SecurityHardeningTests
@@ -23,6 +23,7 @@ public class SecurityHardeningTests
return new JwtTokenService(options, NullLogger<JwtTokenService>.Instance);
}
/// <summary>Verifies the case: ldap Options, transport, defaults To Ldaps.</summary>
[Fact]
public void LdapOptions_Transport_DefaultsToLdaps()
{
@@ -32,6 +33,7 @@ public class SecurityHardeningTests
Assert.Equal(LdapTransport.Ldaps, options.Transport);
}
/// <summary>Verifies the case: ldap Options, allow Insecure, defaults To False.</summary>
[Fact]
public void LdapOptions_AllowInsecure_DefaultsToFalse()
{
@@ -39,6 +41,7 @@ public class SecurityHardeningTests
Assert.False(options.AllowInsecure);
}
/// <summary>Verifies the case: jwt Signing Key, minimum Length, enforced.</summary>
[Fact]
public void JwtSigningKey_MinimumLength_Enforced()
{
@@ -55,6 +58,7 @@ public class SecurityHardeningTests
Assert.True(token.Length > 0);
}
/// <summary>Verifies the case: jwt Signing Key, short Key, fails Validation.</summary>
[Fact]
public void JwtSigningKey_ShortKey_FailsValidation()
{
@@ -63,6 +67,7 @@ public class SecurityHardeningTests
"Test key must be shorter than 32 chars to verify minimum length enforcement");
}
/// <summary>Verifies the case: log Output Template, does Not Contain Secrets.</summary>
[Fact]
public void LogOutputTemplate_DoesNotContainSecrets()
{
@@ -76,6 +81,7 @@ public class SecurityHardeningTests
Assert.DoesNotContain("ConnectionString", template, StringComparison.OrdinalIgnoreCase);
}
/// <summary>Verifies the case: log Enrichment, contains Expected Properties.</summary>
[Fact]
public void LogEnrichment_ContainsExpectedProperties()
{
@@ -88,6 +94,7 @@ public class SecurityHardeningTests
}
}
/// <summary>Verifies the case: jwt Token, does Not Contain Signing Key.</summary>
[Fact]
public void JwtToken_DoesNotContainSigningKey()
{
@@ -103,6 +110,7 @@ public class SecurityHardeningTests
Assert.DoesNotContain("signing-key", token, StringComparison.OrdinalIgnoreCase);
}
/// <summary>Verifies the case: security Options, jwt Expiry Defaults, are Secure.</summary>
[Fact]
public void SecurityOptions_JwtExpiryDefaults_AreSecure()
{
@@ -113,6 +121,7 @@ public class SecurityHardeningTests
Assert.Equal(5, options.JwtRefreshThresholdMinutes);
}
/// <summary>Verifies the case: jwt Token, tampered Payload, fails Validation.</summary>
[Fact]
public void JwtToken_TamperedPayload_FailsValidation()
{
@@ -142,6 +151,7 @@ public class SecurityHardeningTests
Assert.Null(principal);
}
/// <summary>Verifies the case: jwt Refresh Token, preserves Identity.</summary>
[Fact]
public void JwtRefreshToken_PreservesIdentity()
{
@@ -171,6 +181,7 @@ public class SecurityHardeningTests
Assert.Equal("orig_user", refreshedPrincipal.FindFirst(JwtTokenService.UsernameClaimType)?.Value);
}
/// <summary>Verifies the case: startup Validator, rejects Insecure Ldap In Production.</summary>
[Fact]
public void StartupValidator_RejectsInsecureLdapInProduction()
{
@@ -9,12 +9,14 @@ using ZB.MOM.WW.ScadaBridge.TestSupport;
namespace ZB.MOM.WW.ScadaBridge.IntegrationTests;
/// <summary>
/// WP-2 (Phase 8): Full-system failover testing — Site.
/// Full-system failover testing — Site.
/// Verifies S&amp;F buffer takeover, DCL reconnection structure, alarm re-evaluation,
/// and script trigger resumption after site failover.
/// </summary>
public class SiteFailoverTests
{
/// <summary>Verifies that buffered store-and-forward messages are retained across a restart.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Trait("Category", "Integration")]
[Fact]
public async Task StoreAndForward_BufferSurvivesRestart_MessagesRetained()
@@ -72,6 +74,8 @@ public class SiteFailoverTests
}
}
/// <summary>Verifies that parked store-and-forward messages survive a failover.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Trait("Category", "Integration")]
[Fact]
public async Task StoreAndForward_ParkedMessages_SurviveFailover()
@@ -123,6 +127,7 @@ public class SiteFailoverTests
}
}
/// <summary>Verifies that an incoming value triggers alarm re-evaluation and a new alarm state.</summary>
[Fact]
public void AlarmReEvaluation_IncomingValue_TriggersNewState()
{
@@ -151,6 +156,7 @@ public class SiteFailoverTests
Assert.True(clearedEvent.Timestamp > alarmEvent.Timestamp);
}
/// <summary>Verifies that a value change triggers its script after failover.</summary>
[Fact]
public void ScriptTriggerResumption_ValueChangeTriggersScript()
{
@@ -171,6 +177,8 @@ public class SiteFailoverTests
Assert.Equal("Good", valueChange.Quality);
}
/// <summary>Verifies that the store-and-forward buffer depth is reported after a failover.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Trait("Category", "Integration")]
[Fact]
public async Task StoreAndForward_BufferDepth_ReportedAfterFailover()
@@ -4,7 +4,7 @@ using Microsoft.AspNetCore.Mvc.Testing;
namespace ZB.MOM.WW.ScadaBridge.IntegrationTests;
/// <summary>
/// WP-22: Startup validation — missing required config fails with clear error.
/// Startup validation — missing required config fails with clear error.
/// Tests the StartupValidator that runs on boot.
///
/// Note: These tests temporarily set environment variables because Program.cs reads
@@ -13,6 +13,7 @@ namespace ZB.MOM.WW.ScadaBridge.IntegrationTests;
/// </summary>
public class StartupValidationTests
{
/// <summary>Verifies that a missing role throws an InvalidOperationException.</summary>
[Fact]
public void MissingRole_ThrowsInvalidOperationException()
{
@@ -34,6 +35,7 @@ public class StartupValidationTests
factory.Dispose();
}
/// <summary>Verifies that a missing JWT signing key for the Central role throws an InvalidOperationException.</summary>
[Fact]
public void MissingJwtSigningKey_ForCentral_ThrowsInvalidOperationException()
{
@@ -59,6 +61,7 @@ public class StartupValidationTests
factory.Dispose();
}
/// <summary>Verifies that the Central role starts successfully with a valid configuration.</summary>
[Fact]
public void CentralRole_StartsSuccessfully_WithValidConfig()
{
@@ -101,6 +104,8 @@ public class StartupValidationTests
"ScadaBridge__Security__Ldap__ServiceAccountPassword",
};
/// <summary>Sets the given environment variables, remembering the prior values so they can be restored.</summary>
/// <param name="varsToSet">The environment variables to set.</param>
public TempEnvironment(Dictionary<string, string> varsToSet)
{
// Save and clear all known keys
@@ -119,6 +124,7 @@ public class StartupValidationTests
}
}
/// <summary>Releases the resources held by this test class.</summary>
public void Dispose()
{
foreach (var (key, previousValue) in _previousValues)