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:
@@ -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()
|
||||
{
|
||||
|
||||
+11
-2
@@ -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 && thisSide >= 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()
|
||||
|
||||
+10
-1
@@ -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;
|
||||
|
||||
+15
-1
@@ -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&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)
|
||||
|
||||
Reference in New Issue
Block a user