Files
lmxopcua/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/HealthEndpointsHostTests.cs
Joseph Doherty 802366c2c6 Task #154 — driver-diagnostics RPC: HTTP endpoint + Admin client
Foundation for surfacing per-driver runtime state from the Server process to
the Admin UI. #152 shipped GetAutoProhibitedRanges() as an in-process
accessor; #154 makes it reachable across processes.

Server side (HealthEndpointsHost):
- New URL family: /diagnostics/drivers/{driverInstanceId}/{driverType}/{topic}
- First wired topic: /diagnostics/drivers/{id}/modbus/auto-prohibited
- Driver-agnostic at the URL level — future driver types add their own
  segments[3] cases (e.g. /diagnostics/drivers/{id}/s7/dropped-pdus).
- 404 when the driver instance doesn't exist; 400 when the driver exists
  but isn't a Modbus driver (the per-type endpoint is wrong for this row).
- Response shape is flat JSON (unitId / region / startAddress / endAddress /
  lastProbedUtc / bisectionPending) so consumers don't have to reference the
  Driver.Modbus assembly's ModbusAutoProhibition record.
- Re-uses the existing HttpListener bound to localhost:4841 — same auth /
  reachability story as /healthz and /readyz.

Admin side:
- DriverDiagnosticsClient (Services/) — HttpClient wrapper that fetches the
  per-driver Modbus prohibition list. Returns null on 404/400 (driver
  missing or wrong type); throws on transport failures.
- ModbusAutoProhibitionsResponse + ModbusAutoProhibitionRow flat DTOs —
  client doesn't take a dep on Driver.Modbus.
- ModbusDiagnostics.razor at /modbus/diagnostics/{driverInstanceId} —
  table view with BISECTING (warning yellow) / ISOLATED (danger red)
  badges, relative timestamps (e.g. "5m ago"), Refresh button. Errors
  surface inline rather than swallowing.
- HttpClient registration in Program.cs reads
  DriverDiagnostics:ServerBaseUrl from appsettings.json (default
  http://localhost:4841/ for same-host deployments).

Tests (3 new in HealthEndpointsHostTests):
- Diagnostics_ReturnsModbusAutoProhibitions_ForLiveDriver — registers a
  Modbus driver with a programmable transport that protects register 102,
  records the prohibition via a coalesced ReadAsync, hits the endpoint,
  asserts the returned JSON matches (unitId / region / start / end / pending).
- Diagnostics_404_When_Driver_Not_Found
- Diagnostics_400_When_Driver_Is_Wrong_Type

Architecture note: the Admin-side bUnit-style component test isn't included
because Admin.Tests doesn't have bUnit set up. The DriverDiagnosticsClient
is unit-testable on its own with a mock HandlerStub if needed — left as a
follow-up alongside the broader bUnit setup task.

The diagnostic page is now reachable at /modbus/diagnostics/{driverId} from
any Admin instance pointing at a Server endpoint URL. Future driver types
(S7, AbCip) plug into the same channel by adding their own URL segments
in HealthEndpointsHost.WriteDriverDiagnosticsAsync.
2026-04-25 01:32:21 -04:00

248 lines
10 KiB
C#

using System.Net.Http;
using System.Text.Json;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
using ZB.MOM.WW.OtOpcUa.Server.Observability;
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
[Trait("Category", "Integration")]
public sealed class HealthEndpointsHostTests : IAsyncLifetime
{
private static int _portCounter = 48500 + Random.Shared.Next(0, 99);
private readonly int _port = Interlocked.Increment(ref _portCounter);
private string Prefix => $"http://localhost:{_port}/";
private readonly DriverHost _driverHost = new();
private HealthEndpointsHost _host = null!;
private HttpClient _client = null!;
public ValueTask InitializeAsync()
{
_client = new HttpClient { BaseAddress = new Uri(Prefix) };
return ValueTask.CompletedTask;
}
public async ValueTask DisposeAsync()
{
_client.Dispose();
if (_host is not null) await _host.DisposeAsync();
}
private HealthEndpointsHost Start(Func<bool>? configDbHealthy = null, Func<bool>? usingStaleConfig = null)
{
_host = new HealthEndpointsHost(
_driverHost,
NullLogger<HealthEndpointsHost>.Instance,
configDbHealthy,
usingStaleConfig,
prefix: Prefix);
_host.Start();
return _host;
}
[Fact]
public async Task Healthz_ReturnsHealthy_EmptyFleet()
{
Start();
var response = await _client.GetAsync("/healthz");
response.IsSuccessStatusCode.ShouldBeTrue();
var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement;
body.GetProperty("status").GetString().ShouldBe("healthy");
body.GetProperty("configDbReachable").GetBoolean().ShouldBeTrue();
body.GetProperty("usingStaleConfig").GetBoolean().ShouldBeFalse();
}
[Fact]
public async Task Healthz_StaleConfig_Returns200_WithFlag()
{
Start(configDbHealthy: () => false, usingStaleConfig: () => true);
var response = await _client.GetAsync("/healthz");
response.StatusCode.ShouldBe(System.Net.HttpStatusCode.OK);
var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement;
body.GetProperty("configDbReachable").GetBoolean().ShouldBeFalse();
body.GetProperty("usingStaleConfig").GetBoolean().ShouldBeTrue();
}
[Fact]
public async Task Healthz_UnreachableConfig_And_NoCache_Returns503()
{
Start(configDbHealthy: () => false, usingStaleConfig: () => false);
var response = await _client.GetAsync("/healthz");
response.StatusCode.ShouldBe(System.Net.HttpStatusCode.ServiceUnavailable);
}
[Fact]
public async Task Readyz_EmptyFleet_Is200_Healthy()
{
Start();
var response = await _client.GetAsync("/readyz");
response.StatusCode.ShouldBe(System.Net.HttpStatusCode.OK);
var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement;
body.GetProperty("verdict").GetString().ShouldBe("Healthy");
}
[Fact]
public async Task Readyz_WithHealthyDriver_Is200()
{
await _driverHost.RegisterAsync(new StubDriver("drv-1", DriverState.Healthy), "{}", CancellationToken.None);
Start();
var response = await _client.GetAsync("/readyz");
response.StatusCode.ShouldBe(System.Net.HttpStatusCode.OK);
var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement;
body.GetProperty("verdict").GetString().ShouldBe("Healthy");
body.GetProperty("drivers").GetArrayLength().ShouldBe(1);
}
[Fact]
public async Task Readyz_WithFaultedDriver_Is503()
{
await _driverHost.RegisterAsync(new StubDriver("dead", DriverState.Faulted), "{}", CancellationToken.None);
await _driverHost.RegisterAsync(new StubDriver("alive", DriverState.Healthy), "{}", CancellationToken.None);
Start();
var response = await _client.GetAsync("/readyz");
response.StatusCode.ShouldBe(System.Net.HttpStatusCode.ServiceUnavailable);
var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement;
body.GetProperty("verdict").GetString().ShouldBe("Faulted");
}
[Fact]
public async Task Readyz_WithDegradedDriver_Is200_WithDegradedList()
{
await _driverHost.RegisterAsync(new StubDriver("drv-ok", DriverState.Healthy), "{}", CancellationToken.None);
await _driverHost.RegisterAsync(new StubDriver("drv-deg", DriverState.Degraded), "{}", CancellationToken.None);
Start();
var response = await _client.GetAsync("/readyz");
response.StatusCode.ShouldBe(System.Net.HttpStatusCode.OK);
var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement;
body.GetProperty("verdict").GetString().ShouldBe("Degraded");
body.GetProperty("degradedDrivers").GetArrayLength().ShouldBe(1);
body.GetProperty("degradedDrivers")[0].GetString().ShouldBe("drv-deg");
}
[Fact]
public async Task Readyz_WithInitializingDriver_Is503()
{
await _driverHost.RegisterAsync(new StubDriver("init", DriverState.Initializing), "{}", CancellationToken.None);
Start();
var response = await _client.GetAsync("/readyz");
response.StatusCode.ShouldBe(System.Net.HttpStatusCode.ServiceUnavailable);
}
[Fact]
public async Task Unknown_Path_Returns404()
{
Start();
var response = await _client.GetAsync("/foo");
response.StatusCode.ShouldBe(System.Net.HttpStatusCode.NotFound);
}
// ===== #154 — driver-diagnostics endpoint =====
[Fact]
public async Task Diagnostics_ReturnsModbusAutoProhibitions_ForLiveDriver()
{
// Bring up a Modbus driver with a programmable transport that protects register 102,
// record one prohibition, then hit /diagnostics/drivers/{id}/modbus/auto-prohibited.
var fake = new ModbusDriverDiagnosticsTransport { ProtectedAddress = 102 };
var t1 = new ZB.MOM.WW.OtOpcUa.Driver.Modbus.ModbusTagDefinition(
"T1", ZB.MOM.WW.OtOpcUa.Driver.Modbus.ModbusRegion.HoldingRegisters, 100, ZB.MOM.WW.OtOpcUa.Driver.Modbus.ModbusDataType.Int16);
var t2 = new ZB.MOM.WW.OtOpcUa.Driver.Modbus.ModbusTagDefinition(
"T2", ZB.MOM.WW.OtOpcUa.Driver.Modbus.ModbusRegion.HoldingRegisters, 102, ZB.MOM.WW.OtOpcUa.Driver.Modbus.ModbusDataType.Int16);
var opts = new ZB.MOM.WW.OtOpcUa.Driver.Modbus.ModbusDriverOptions
{
Host = "f", Tags = [t1, t2], MaxReadGap = 5,
Probe = new ZB.MOM.WW.OtOpcUa.Driver.Modbus.ModbusProbeOptions { Enabled = false },
};
var driver = new ZB.MOM.WW.OtOpcUa.Driver.Modbus.ModbusDriver(opts, "diag-mb", _ => fake);
await _driverHost.RegisterAsync(driver, "{}", CancellationToken.None);
await driver.ReadAsync(["T1", "T2"], CancellationToken.None);
Start();
var response = await _client.GetAsync("/diagnostics/drivers/diag-mb/modbus/auto-prohibited");
response.IsSuccessStatusCode.ShouldBeTrue();
var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement;
body.GetProperty("driverInstanceId").GetString().ShouldBe("diag-mb");
body.GetProperty("count").GetInt32().ShouldBe(1);
var first = body.GetProperty("ranges")[0];
first.GetProperty("startAddress").GetInt32().ShouldBe(100);
first.GetProperty("endAddress").GetInt32().ShouldBe(102);
first.GetProperty("region").GetString().ShouldBe("HoldingRegisters");
first.GetProperty("bisectionPending").GetBoolean().ShouldBeTrue();
}
[Fact]
public async Task Diagnostics_404_When_Driver_Not_Found()
{
Start();
var response = await _client.GetAsync("/diagnostics/drivers/no-such/modbus/auto-prohibited");
response.StatusCode.ShouldBe(System.Net.HttpStatusCode.NotFound);
}
[Fact]
public async Task Diagnostics_400_When_Driver_Is_Wrong_Type()
{
await _driverHost.RegisterAsync(new StubDriver("not-modbus", DriverState.Healthy), "{}", CancellationToken.None);
Start();
var response = await _client.GetAsync("/diagnostics/drivers/not-modbus/modbus/auto-prohibited");
response.StatusCode.ShouldBe(System.Net.HttpStatusCode.BadRequest);
}
private sealed class ModbusDriverDiagnosticsTransport : ZB.MOM.WW.OtOpcUa.Driver.Modbus.IModbusTransport
{
public ushort ProtectedAddress { get; set; } = 102;
public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask;
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
{
var addr = (ushort)((pdu[1] << 8) | pdu[2]);
var qty = (ushort)((pdu[3] << 8) | pdu[4]);
if (pdu[0] is 0x03 && ProtectedAddress >= addr && ProtectedAddress < addr + qty)
return Task.FromException<byte[]>(new ZB.MOM.WW.OtOpcUa.Driver.Modbus.ModbusException(0x03, 0x02, "IllegalDataAddress"));
var resp = new byte[2 + qty * 2];
resp[0] = pdu[0]; resp[1] = (byte)(qty * 2);
return Task.FromResult(resp);
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
private sealed class StubDriver : IDriver
{
private readonly DriverState _state;
public StubDriver(string id, DriverState state)
{
DriverInstanceId = id;
_state = state;
}
public string DriverInstanceId { get; }
public string DriverType => "Stub";
public Task InitializeAsync(string _, CancellationToken ct) => Task.CompletedTask;
public Task ReinitializeAsync(string _, CancellationToken ct) => Task.CompletedTask;
public Task ShutdownAsync(CancellationToken ct) => Task.CompletedTask;
public DriverHealth GetHealth() => new(_state, null, null);
public long GetMemoryFootprint() => 0;
public Task FlushOptionalCachesAsync(CancellationToken ct) => Task.CompletedTask;
}
}