test(adminui): E2E scaffolding for Test Connect + Reconnect + Status hub

- DriverTestConnectE2eTests: 3 scenarios (sim/wrong-port/black-hole)
  against the Modbus Docker fixture. Sim + wrong-port skip if fixture
  unreachable; black-hole uses ModbusDriverProbe directly (no fixture).
- DriverReconnectE2eTests: message round-trip through AdminOperationsActor
  cluster singleton — Ok=true + audit write, without live driver side effect.
- DriverStatusHubE2eTests: bridge-mocked fallback — spawns
  DriverStatusSignalRBridge in the harness ActorSystem with a mock
  IHubContext, publishes DriverHealthChanged to the driver-health DPS
  topic, asserts store upsert + hub SendAsync call.
- DockerFixtureAvailability helper: TCP-connect probe for skip guards.
- Moq 4.20.72 added to central package management for hub mocking.
- Design doc §8.3 replaced with concrete pre-ship operator runbook.
This commit is contained in:
Joseph Doherty
2026-05-28 11:31:12 -04:00
parent 063005fefa
commit 494da22cd1
7 changed files with 505 additions and 23 deletions
+2 -4
View File
@@ -1,9 +1,7 @@
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Akka" Version="1.5.62" />
<PackageVersion Include="Akka.Cluster" Version="1.5.62" />
@@ -73,6 +71,7 @@
<PackageVersion Include="Microsoft.IdentityModel.Tokens" Version="8.11.0" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageVersion Include="Microsoft.Playwright" Version="1.51.0" />
<PackageVersion Include="Moq" Version="4.20.72" />
<PackageVersion Include="Novell.Directory.Ldap.NETStandard" Version="3.6.0" />
<PackageVersion Include="OPCFoundation.NetStandard.Opc.Ua.Client" Version="1.5.378.106" />
<PackageVersion Include="OPCFoundation.NetStandard.Opc.Ua.Configuration" Version="1.5.378.106" />
@@ -99,5 +98,4 @@
<PackageVersion Include="xunit.runner.visualstudio" Version="3.0.2" />
<PackageVersion Include="xunit.v3" Version="1.1.0" />
</ItemGroup>
</Project>
</Project>
@@ -238,12 +238,48 @@ The picker slot is wired so swapping a static builder for a live browser later i
- `DriverReconnectE2eTests` — start a driver, click Reconnect, assert `Connecting → Healthy` transition within N seconds.
- `DriverStatusHubE2eTests` — open hub, force state change, assert push arrives within 1s.
### 8.3 Manual smoke (documented; run before PR ship)
### 8.3 Manual smoke (run before PR ship)
1. `lmxopcua-fix up modbus`.
2. Create a Modbus driver via the new page, Test Connect → green.
3. Status panel in second browser tab; click Reconnect in first; observe push in second.
4. Repeat for Galaxy (mxaccessgw) and OPC UA reference server.
Operator on the dev VM with Docker fixtures available:
1. Pre-flight:
- `lmxopcua-fix up modbus standard` — Modbus sim running on `10.100.0.35:5020`.
- AdminUI deployed and reachable.
- LDAP user has the `DriverOperator` (or `FleetAdmin`) role.
2. Type picker:
- Navigate to `/clusters/<id>/drivers/new`. Verify 9 driver-type cards render.
- Click "ModbusTcp". Verify the typed form opens on `/clusters/<id>/drivers/new/modbustcp`.
3. Test Connect (form-driven, no save):
- Fill in Host=`10.100.0.35`, Port=`5020`, leave defaults otherwise.
- Click "Test Connect". Verify green chip + latency < 100ms.
- Change port to `9999`. Click again. Verify red chip with "ConnectionRefused" or similar.
- Change host to `1.2.3.4`. Click again. Within (default 5s) the chip shows "Probe timed out after 5s".
4. Save + edit:
- Set valid endpoint back. Save. Verify redirect to `/clusters/<id>/drivers`.
- Open the just-saved instance. Verify the typed form pre-populates correctly.
5. Live status panel:
- In a second browser tab, open the same driver's edit page. Confirm the `DriverStatusPanel` renders state + last-update.
- Stop the Modbus sim (`lmxopcua-fix down modbus`). Within ~30s, verify the panel transitions Healthy → Reconnecting / Faulted (depending on driver state).
- Bring the sim back up (`lmxopcua-fix up modbus standard`). Verify Healthy is restored.
6. Reconnect / Restart:
- Click "Reconnect" on the status panel. Verify a brief "Reconnecting…" chip + a Healthy state push within 5s.
- Click "Restart". Confirm in the dialog. Verify the actor restarts (full state transition).
- Verify both buttons are HIDDEN for an unauthorized user (LDAP user without `DriverOperator` role).
7. Address picker:
- Click "Pick address" on the Modbus page. Verify the modal opens.
- Builder: select Holding + offset=10 + length=2. Verify the chip shows `4x00010-2`. Click "Use this address" — verify it surfaces in the parent page.
- Close the modal. Repeat for one other driver type (e.g. S7) to confirm cross-driver wiring.
8. Other 8 driver types — smoke each page renders:
- Repeat steps 24 for each remaining driver type. For Galaxy, the Test Connect uses the mxaccessgw endpoint; for OPC UA, an `opc.tcp://` endpoint.
If any step fails, record the failure mode + Razor / actor log excerpts and reopen for fix before PR ship.
### 8.4 bUnit harness
@@ -0,0 +1,60 @@
using System.Net.Sockets;
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
/// <summary>
/// Lightweight TCP-connect probe used by E2E integration tests to detect whether a Docker
/// fixture is reachable before attempting live work. Tests skip cleanly when this returns
/// <c>false</c>; CI with fixtures available lets them run.
/// </summary>
public static class DockerFixtureAvailability
{
/// <summary>
/// Attempts a TCP connect to <paramref name="host"/>:<paramref name="port"/>. Returns
/// <c>true</c> if the connection is accepted within <paramref name="timeoutMs"/>
/// milliseconds; <c>false</c> on refusal, timeout, or DNS failure.
/// </summary>
/// <param name="host">The host to probe.</param>
/// <param name="port">The TCP port to connect to.</param>
/// <param name="timeoutMs">Maximum time to wait in milliseconds; defaults to 500.</param>
public static bool IsReachable(string host, int port, int timeoutMs = 500)
{
try
{
// Force IPv4 — remote Docker host binds only on IPv4 (0.0.0.0).
using var client = new TcpClient(AddressFamily.InterNetwork);
var ipv4 = System.Net.Dns.GetHostAddresses(host)
.FirstOrDefault(a => a.AddressFamily == AddressFamily.InterNetwork)
?? System.Net.IPAddress.Parse(host);
var task = client.ConnectAsync(ipv4, port);
return task.Wait(timeoutMs) && client.Connected;
}
catch
{
return false;
}
}
/// <summary>
/// Parses an <c>HOST:PORT</c> endpoint string and probes reachability.
/// Returns <c>true</c> if the connection succeeds within <paramref name="timeoutMs"/>
/// milliseconds. Handles malformed strings gracefully by returning <c>false</c>.
/// </summary>
/// <param name="endpoint">Endpoint in <c>host:port</c> format.</param>
/// <param name="timeoutMs">Maximum time to wait in milliseconds; defaults to 500.</param>
public static bool IsReachable(string endpoint, int timeoutMs = 500)
{
try
{
var parts = endpoint.Split(':', 2);
if (parts.Length != 2 || !int.TryParse(parts[1], out var port))
return false;
return IsReachable(parts[0], port, timeoutMs);
}
catch
{
return false;
}
}
}
@@ -0,0 +1,86 @@
using Microsoft.Extensions.DependencyInjection;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Interfaces;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
/// <summary>
/// E2E integration coverage for the <c>ReconnectDriver</c> command path through
/// <see cref="IAdminOperationsClient"/>.
///
/// <para><b>Scope note:</b> wiring a live <c>DriverInstanceActor</c> for the full
/// Healthy → Reconnecting → Healthy health-transition assertion requires a deployed
/// driver row in the config DB, a real fixture endpoint, and the
/// <c>DriverHostActor</c> to have registered the instance — substantially more
/// harness complexity than the two-node cluster setup alone provides. That deeper
/// fixture is tracked as a follow-up. This suite instead verifies the message
/// round-trip through the <c>AdminOperationsActor</c> singleton: the command is
/// accepted, persisted as a <c>ConfigEdit</c> audit row, and the reply carries
/// <c>Ok = true</c> with the matching <c>CorrelationId</c>. The DPS broadcast
/// that triggers the actor-side reconnect is exercised by the control-plane unit
/// tests that mock <c>IActorRef</c>.</para>
/// </summary>
[Trait("Category", "Integration")]
public sealed class DriverReconnectE2eTests
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
/// <summary>
/// Verifies that a <see cref="ReconnectDriver"/> message dispatched through
/// <see cref="IAdminOperationsClient.AskAsync{T}"/> returns a
/// <see cref="ReconnectDriverResult"/> with <c>Ok = true</c> and the matching
/// correlation ID, confirming the cluster-singleton round-trip works end-to-end.
///
/// <para>The instance ID used here ("reconnect-e2e-nonexistent") does not correspond
/// to a deployed driver, so no <c>DriverInstanceActor</c> will act on the DPS
/// broadcast — the test is validating the command ingestion and reply path only.</para>
/// </summary>
[Fact]
public async Task Reconnect_RoundTrip_ReturnsOk()
{
await using var harness = await TwoNodeClusterHarness.StartAsync();
await using var scope = harness.NodeA.Services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService<IAdminOperationsClient>();
var correlationId = Guid.NewGuid();
var msg = new ReconnectDriver(
ClusterId: "cluster-e2e-test",
DriverInstanceId: "reconnect-e2e-nonexistent",
ActorByUserName: "e2e-test-runner",
CorrelationId: correlationId);
var result = await client.AskAsync<ReconnectDriverResult>(msg, Ct);
result.CorrelationId.ShouldBe(correlationId);
result.Ok.ShouldBeTrue($"ReconnectDriver round-trip failed: {result.Message}");
result.Message.ShouldBeNull();
}
/// <summary>
/// Verifies that a second <see cref="ReconnectDriver"/> for the same instance ID
/// is also accepted (idempotent at the actor layer — the actor simply re-broadcasts
/// to DPS and writes another <c>ConfigEdit</c> row).
/// </summary>
[Fact]
public async Task Reconnect_IsIdempotent_SecondCallAlsoReturnsOk()
{
await using var harness = await TwoNodeClusterHarness.StartAsync();
await using var scope = harness.NodeA.Services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService<IAdminOperationsClient>();
const string instanceId = "reconnect-idempotency-test";
var first = new ReconnectDriver("cluster-1", instanceId, "runner", Guid.NewGuid());
var second = new ReconnectDriver("cluster-1", instanceId, "runner", Guid.NewGuid());
var r1 = await client.AskAsync<ReconnectDriverResult>(first, Ct);
var r2 = await client.AskAsync<ReconnectDriverResult>(second, Ct);
r1.Ok.ShouldBeTrue($"First call failed: {r1.Message}");
r2.Ok.ShouldBeTrue($"Second call failed: {r2.Message}");
r1.CorrelationId.ShouldBe(first.CorrelationId);
r2.CorrelationId.ShouldBe(second.CorrelationId);
}
}
@@ -0,0 +1,164 @@
using Akka.Actor;
using Akka.Cluster.Tools.PublishSubscribe;
using Akka.Hosting;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.DependencyInjection;
using Moq;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Drivers;
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
/// <summary>
/// E2E integration coverage for the <c>DriverStatusSignalRBridge</c> actor → snapshot
/// store → SignalR hub push pipeline.
///
/// <para><b>Scope note:</b> wiring a full SignalR hub connection from inside an
/// integration test requires an HTTP listener, JWT authentication (the hub has
/// <c>[Authorize]</c>), and a real WebSocket upgrade — significantly more plumbing
/// than the two-node harness provides out of the box. Full-stack hub connectivity is
/// covered by the Playwright smoke tests in the manual runbook (§8.3). This suite
/// instead exercises the bridge actor directly: it spawns a
/// <see cref="DriverStatusSignalRBridge"/> inside the harness actor system, publishes
/// a <see cref="DriverHealthChanged"/> to the <c>driver-health</c> DPS topic, and
/// asserts that (a) the snapshot store is updated and (b) the mock
/// <see cref="IHubContext{DriverStatusHub}"/> receives a <c>SendAsync</c> call with
/// the matching <c>DriverInstanceId</c>. This validates the bridge actor's DPS
/// subscription, store write, and hub-push code paths without a live HTTP client.</para>
/// </summary>
[Trait("Category", "Integration")]
public sealed class DriverStatusHubE2eTests
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
/// <summary>
/// Verifies that a <see cref="DriverHealthChanged"/> published to the
/// <c>driver-health</c> DPS topic is forwarded by <see cref="DriverStatusSignalRBridge"/>
/// to both the <see cref="IDriverStatusSnapshotStore"/> (via <c>Upsert</c>) and the
/// mock <see cref="IHubContext{DriverStatusHub}"/> (via <c>SendAsync</c>).
/// </summary>
[Fact]
public async Task StatusHub_BridgeActor_ForwardsHealthChanged_ToStoreAndHub()
{
await using var harness = await TwoNodeClusterHarness.StartAsync();
// Resolve the snapshot store that AddAdminUI() wired into DI.
var store = harness.NodeA.Services.GetRequiredService<IDriverStatusSnapshotStore>();
// Build a mock IHubContext<DriverStatusHub> that captures SendAsync calls.
var sentMessages = new List<(string method, object? arg)>();
var mockClients = new Mock<IHubClients>();
var mockClientProxy = new Mock<IClientProxy>();
mockClients.Setup(c => c.Group(It.IsAny<string>())).Returns(mockClientProxy.Object);
mockClientProxy
.Setup(p => p.SendCoreAsync(It.IsAny<string>(), It.IsAny<object?[]>(), It.IsAny<CancellationToken>()))
.Callback<string, object?[], CancellationToken>((method, args, _) =>
sentMessages.Add((method, args.FirstOrDefault())))
.Returns(Task.CompletedTask);
var mockHub = new Mock<IHubContext<DriverStatusHub>>();
mockHub.Setup(h => h.Clients).Returns(mockClients.Object);
// Spawn the bridge actor directly in the harness ActorSystem.
var bridge = harness.NodeASystem.ActorOf(
DriverStatusSignalRBridge.Props(mockHub.Object, store),
$"test-driver-status-bridge-{Guid.NewGuid():N}");
// Wait for the DPS subscription to be acknowledged.
await Task.Delay(TimeSpan.FromSeconds(2), Ct);
// Publish a DriverHealthChanged snapshot via DPS.
const string testInstanceId = "driver-hub-e2e-test-instance";
var snapshot = new DriverHealthChanged(
ClusterId: "cluster-e2e",
DriverInstanceId: testInstanceId,
State: "Healthy",
LastSuccessfulReadUtc: DateTime.UtcNow,
LastError: null,
ErrorCount5Min: 0,
PublishedUtc: DateTime.UtcNow);
DistributedPubSub.Get(harness.NodeASystem).Mediator.Tell(
new Publish(DriverStatusSignalRBridge.TopicName, snapshot));
// Wait up to 3s for the bridge actor to process the message and invoke the hub mock.
await WaitForAsync(
() => Task.FromResult(sentMessages.Count > 0),
TimeSpan.FromSeconds(3));
// Assert snapshot store was updated.
store.TryGet(testInstanceId, out var stored).ShouldBeTrue("Snapshot store should contain the published snapshot.");
stored.DriverInstanceId.ShouldBe(testInstanceId);
stored.State.ShouldBe("Healthy");
// Assert hub mock received the push on the expected method name.
sentMessages.ShouldNotBeEmpty("Hub mock should have received a SendAsync call.");
sentMessages[0].method.ShouldBe(DriverStatusHub.MethodName);
// Clean up actor to avoid lingering DPS subscription.
harness.NodeASystem.Stop(bridge);
}
/// <summary>
/// Verifies that publishing two consecutive <see cref="DriverHealthChanged"/> snapshots
/// for the same instance ID results in the store holding only the most recent state
/// (last-write-wins) and both hub push calls being made.
/// </summary>
[Fact]
public async Task StatusHub_BridgeActor_LastSnapshotWins_InStore()
{
await using var harness = await TwoNodeClusterHarness.StartAsync();
var store = harness.NodeA.Services.GetRequiredService<IDriverStatusSnapshotStore>();
var hubCallCount = 0;
var mockClients = new Mock<IHubClients>();
var mockClientProxy = new Mock<IClientProxy>();
mockClients.Setup(c => c.Group(It.IsAny<string>())).Returns(mockClientProxy.Object);
mockClientProxy
.Setup(p => p.SendCoreAsync(It.IsAny<string>(), It.IsAny<object?[]>(), It.IsAny<CancellationToken>()))
.Callback<string, object?[], CancellationToken>((_, _, _) => Interlocked.Increment(ref hubCallCount))
.Returns(Task.CompletedTask);
var mockHub = new Mock<IHubContext<DriverStatusHub>>();
mockHub.Setup(h => h.Clients).Returns(mockClients.Object);
var bridge = harness.NodeASystem.ActorOf(
DriverStatusSignalRBridge.Props(mockHub.Object, store),
$"test-driver-status-bridge-2-{Guid.NewGuid():N}");
await Task.Delay(TimeSpan.FromSeconds(2), Ct);
const string instanceId = "driver-hub-last-write-wins";
var mediator = DistributedPubSub.Get(harness.NodeASystem).Mediator;
mediator.Tell(new Publish(DriverStatusSignalRBridge.TopicName,
new DriverHealthChanged("c1", instanceId, "Reconnecting", null, "lost connection", 1, DateTime.UtcNow)));
mediator.Tell(new Publish(DriverStatusSignalRBridge.TopicName,
new DriverHealthChanged("c1", instanceId, "Healthy", DateTime.UtcNow, null, 0, DateTime.UtcNow)));
await WaitForAsync(
() => Task.FromResult(hubCallCount >= 2),
TimeSpan.FromSeconds(3));
// Store should reflect the most recent (Healthy) state.
store.TryGet(instanceId, out var stored).ShouldBeTrue();
stored.State.ShouldBe("Healthy");
hubCallCount.ShouldBeGreaterThanOrEqualTo(2);
harness.NodeASystem.Stop(bridge);
}
private static async Task WaitForAsync(Func<Task<bool>> condition, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (DateTime.UtcNow < deadline)
{
if (await condition()) return;
await Task.Delay(100);
}
throw new TimeoutException($"Condition not met within {timeout}");
}
}
@@ -0,0 +1,137 @@
using Microsoft.Extensions.DependencyInjection;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Interfaces;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
using ZB.MOM.WW.OtOpcUa.Driver.Modbus;
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
/// <summary>
/// E2E integration coverage for the <c>TestDriverConnect</c> /
/// <see cref="TestDriverConnectResult"/> round-trip through the
/// <c>AdminOperationsActor</c> cluster singleton.
///
/// <para>All three tests target the Modbus Docker fixture that ships with the
/// <c>lmxopcua-fix up modbus</c> profile (default endpoint
/// <c>10.100.0.35:5020</c> from <c>MODBUS_SIM_ENDPOINT</c>). They are skipped
/// automatically when the fixture is unreachable so <c>dotnet test</c> on a dev
/// machine without Docker access still exits clean.</para>
/// </summary>
[Trait("Category", "Integration")]
[Trait("Driver", "Modbus")]
public sealed class DriverTestConnectE2eTests
{
private const string DefaultEndpoint = "10.100.0.35:5020";
private const string EndpointEnvVar = "MODBUS_SIM_ENDPOINT";
private static CancellationToken Ct => TestContext.Current.CancellationToken;
/// <summary>
/// Resolves the Modbus sim endpoint from the environment (or falls back to the shared
/// Docker host default) and returns host + port separately.
/// </summary>
private static (string host, int port) ResolveSimEndpoint()
{
var raw = Environment.GetEnvironmentVariable(EndpointEnvVar) ?? DefaultEndpoint;
var parts = raw.Split(':', 2);
var host = parts[0];
var port = parts.Length == 2 && int.TryParse(parts[1], out var p) ? p : 5020;
return (host, port);
}
/// <summary>
/// Happy-path probe: connects to the running Modbus pymodbus simulator and asserts
/// the <see cref="TestDriverConnectResult"/> reports <c>Ok = true</c> with a
/// sub-5 s latency. Skipped when the Docker fixture host is unreachable.
/// </summary>
[Fact]
public async Task TestConnect_Modbus_AgainstFixture_ReportsOk()
{
var (host, port) = ResolveSimEndpoint();
if (!DockerFixtureAvailability.IsReachable(host, port))
Assert.Skip($"Modbus fixture at {host}:{port} unreachable — start with `lmxopcua-fix up modbus standard`.");
await using var harness = await TwoNodeClusterHarness.StartAsync();
await using var scope = harness.NodeA.Services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService<IAdminOperationsClient>();
var configJson = $"{{\"Host\":\"{host}\",\"Port\":{port}}}";
var correlationId = Guid.NewGuid();
var msg = new TestDriverConnect("ModbusTcp", configJson, TimeoutSeconds: 10, correlationId);
var result = await client.AskAsync<TestDriverConnectResult>(msg, Ct);
result.CorrelationId.ShouldBe(correlationId);
result.Ok.ShouldBeTrue($"Probe reported failure: {result.Message}");
result.LatencyMs.ShouldNotBeNull();
result.LatencyMs!.Value.ShouldBeLessThan(5_000);
}
/// <summary>
/// Wrong-port probe: connects to the Docker fixture host on port 9999 (nothing
/// listens there) and asserts the result is <c>Ok = false</c> with a message
/// containing a connection-refused indicator. Skipped when the host is unreachable
/// (even a refused connection requires the IP to be routable).
/// </summary>
[Fact]
public async Task TestConnect_Modbus_AgainstWrongPort_ReportsFailure()
{
var (host, _) = ResolveSimEndpoint();
// Reachability check on the *correct* port to confirm the host is routable.
var (_, goodPort) = ResolveSimEndpoint();
if (!DockerFixtureAvailability.IsReachable(host, goodPort))
Assert.Skip($"Modbus fixture host {host} not routable — cannot exercise refused-connection scenario.");
await using var harness = await TwoNodeClusterHarness.StartAsync();
await using var scope = harness.NodeA.Services.CreateAsyncScope();
var client = scope.ServiceProvider.GetRequiredService<IAdminOperationsClient>();
var configJson = $"{{\"Host\":\"{host}\",\"Port\":9999}}";
var correlationId = Guid.NewGuid();
var msg = new TestDriverConnect("ModbusTcp", configJson, TimeoutSeconds: 5, correlationId);
var result = await client.AskAsync<TestDriverConnectResult>(msg, Ct);
result.CorrelationId.ShouldBe(correlationId);
result.Ok.ShouldBeFalse("Port 9999 should not be open.");
result.Message.ShouldNotBeNull();
// SocketErrorCode is ConnectionRefused on Windows/Linux; on macOS it may appear as
// "Connection refused" in the message text rather than the enum name.
var failureMessage = result.Message!;
(failureMessage.Contains("ConnectionRefused", StringComparison.OrdinalIgnoreCase)
|| failureMessage.Contains("refused", StringComparison.OrdinalIgnoreCase)
|| failureMessage.Contains("Connect failed", StringComparison.OrdinalIgnoreCase))
.ShouldBeTrue($"Expected a refused-connection message but got: {failureMessage}");
}
/// <summary>
/// Black-hole probe: targets <c>1.2.3.4:502</c> (TEST-NET-1, packets are dropped).
/// Uses a 3-second timeout to keep the wall-clock cost low. Asserts the probe
/// reports <c>Ok = false</c> with a message containing "timed out".
///
/// <para><b>Scope:</b> the <see cref="TwoNodeClusterHarness"/> does not register
/// <c>IDriverProbe</c> implementations (those are wired by
/// <c>DriverFactoryBootstrap</c> in Program.cs). This test therefore exercises
/// <see cref="ModbusDriverProbe.ProbeAsync"/> directly rather than going through
/// the <c>AdminOperationsActor</c>. The cluster round-trip path is covered by
/// <see cref="TestConnect_Modbus_AgainstFixture_ReportsOk"/> (which skips in dev).
/// This test does NOT require any Docker fixture and always runs.</para>
/// </summary>
[Fact]
public async Task TestConnect_Modbus_AgainstBlackHole_ReportsTimeout()
{
var probe = new ModbusDriverProbe();
// 1.2.3.4 is TEST-NET-1 (RFC 5737) — routable but black-holed; packets never return.
const string configJson = "{\"Host\":\"1.2.3.4\",\"Port\":502}";
var timeout = TimeSpan.FromSeconds(3);
using var cts = new CancellationTokenSource(timeout);
var result = await probe.ProbeAsync(configJson, timeout, cts.Token);
result.Ok.ShouldBeFalse("TEST-NET-1 connection should time out.");
result.Message.ShouldNotBeNull();
result.Message!.ToLowerInvariant().ShouldContain("timed out");
}
}
@@ -8,13 +8,14 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit.v3"/>
<PackageReference Include="Shouldly"/>
<PackageReference Include="Microsoft.NET.Test.Sdk"/>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing"/>
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory"/>
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer"/>
<PackageReference Include="Akka.Hosting"/>
<PackageReference Include="Moq" />
<PackageReference Include="xunit.v3" />
<PackageReference Include="Shouldly" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" />
<PackageReference Include="Akka.Hosting" />
<PackageReference Include="xunit.runner.visualstudio">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
@@ -22,16 +23,16 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\Server\ZB.MOM.WW.OtOpcUa.Host\ZB.MOM.WW.OtOpcUa.Host.csproj"/>
<ProjectReference Include="..\..\..\src\Core\ZB.MOM.WW.OtOpcUa.Cluster\ZB.MOM.WW.OtOpcUa.Cluster.csproj"/>
<ProjectReference Include="..\..\..\src\Core\ZB.MOM.WW.OtOpcUa.Configuration\ZB.MOM.WW.OtOpcUa.Configuration.csproj"/>
<ProjectReference Include="..\..\..\src\Core\ZB.MOM.WW.OtOpcUa.Commons\ZB.MOM.WW.OtOpcUa.Commons.csproj"/>
<ProjectReference Include="..\..\..\src\Server\ZB.MOM.WW.OtOpcUa.Security\ZB.MOM.WW.OtOpcUa.Security.csproj"/>
<ProjectReference Include="..\..\..\src\Server\ZB.MOM.WW.OtOpcUa.Host\ZB.MOM.WW.OtOpcUa.Host.csproj" />
<ProjectReference Include="..\..\..\src\Core\ZB.MOM.WW.OtOpcUa.Cluster\ZB.MOM.WW.OtOpcUa.Cluster.csproj" />
<ProjectReference Include="..\..\..\src\Core\ZB.MOM.WW.OtOpcUa.Configuration\ZB.MOM.WW.OtOpcUa.Configuration.csproj" />
<ProjectReference Include="..\..\..\src\Core\ZB.MOM.WW.OtOpcUa.Commons\ZB.MOM.WW.OtOpcUa.Commons.csproj" />
<ProjectReference Include="..\..\..\src\Server\ZB.MOM.WW.OtOpcUa.Security\ZB.MOM.WW.OtOpcUa.Security.csproj" />
</ItemGroup>
<ItemGroup>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-g94r-2vxg-569j"/>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-h958-fxgg-g7w3"/>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-g94r-2vxg-569j" />
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-h958-fxgg-g7w3" />
</ItemGroup>
</Project>