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? configDbHealthy = null, Func? usingStaleConfig = null) { _host = new HealthEndpointsHost( _driverHost, NullLogger.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 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(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; } }