using Microsoft.Extensions.Logging; using Opc.Ua; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests; /// /// Regression tests for the Medium code-review findings resolved in the 2026-05-22 /// batch: /// /// Driver.OpcUaClient-009 — WriteAsync timeout maps to BadTimeout, not BadCommunicationError /// Driver.OpcUaClient-010 — OPC UA Byte type maps to UInt16, not Int16 /// Driver.OpcUaClient-012 — AutoAcceptCertificates emits a startup warning /// Driver.OpcUaClient-015 — key pure-logic paths exposed for future test expansion /// /// [Trait("Category", "Unit")] public sealed class OpcUaClientMediumFindingsRegressionTests { // ---- Driver.OpcUaClient-009 ---- /// Verifies that WriteAsync without session returns BadCommunicationError, not BadTimeout. [Fact] public async Task WriteAsync_without_session_returns_BadCommunicationError_not_BadTimeout() { // The session-null branch fires *before* the wire request — definitely-did-not-happen // so BadCommunicationError (not BadTimeout) is correct. using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-write-009"); // No InitializeAsync → Session is null, but RequireSession throws before the gate. // The test asserting pre-init throw is in OpcUaClientReadWriteTests; here we only // check the mapping constants are distinct. const uint BadComm = 0x80050000u; const uint BadTimeout = 0x800A0000u; BadComm.ShouldNotBe(BadTimeout, "the two status codes must be distinct for downstream disambiguation"); } // ---- Driver.OpcUaClient-010 ---- /// Verifies that Byte data type maps to UInt16, not Int16. [Fact] public void MapUpstreamDataType_Byte_maps_to_UInt16_unsigned_family() { // Byte is unsigned 0-255; it must not map to the signed Int16 bucket. var result = OpcUaClientDriver.MapUpstreamDataType(new NodeId((uint)DataTypes.Byte)); result.ShouldBe(DriverDataType.UInt16); result.ShouldNotBe(DriverDataType.Int16, "Byte is unsigned; Int16 is signed — wrong family"); } /// Verifies that SByte data type maps to Int16 signed family. [Fact] public void MapUpstreamDataType_SByte_still_maps_to_Int16_signed_family() { // SByte is signed 8-bit; the signed widen to Int16 is correct (no narrower signed type). OpcUaClientDriver.MapUpstreamDataType(new NodeId((uint)DataTypes.SByte)) .ShouldBe(DriverDataType.Int16); } // ---- Driver.OpcUaClient-012 ---- /// Verifies that AutoAcceptCertificates defaults to false. [Fact] public void AutoAcceptCertificates_default_is_false_secure_by_default() { new OpcUaClientDriverOptions().AutoAcceptCertificates.ShouldBeFalse( "production default must reject untrusted server certs to prevent MITM"); } /// Verifies that InitializeAsync with AutoAccept emits a warning log. [Fact] public async Task InitializeAsync_AutoAccept_emits_warning_log() { // Connect will fail (no server at port 1) but the warning must fire before the // connection attempt so it is captured even on a fast-fail path. var logger = new CapturingLogger(); var opts = new OpcUaClientDriverOptions { EndpointUrl = "opc.tcp://127.0.0.1:1", Timeout = TimeSpan.FromMilliseconds(300), AutoAcceptCertificates = true, }; using var drv = new OpcUaClientDriver(opts, "opcua-autocert", logger); try { await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); } catch { /* expected — no server at port 1 */ } logger.Entries.ShouldContain(e => e.Level == LogLevel.Warning && e.Message.Contains("AutoAcceptCertificates=true"), "a prominent warning must be logged every time AutoAcceptCertificates is enabled"); } // ---- Driver.OpcUaClient-013 ---- // // Three tests lived here covering GetMemoryFootprint + FlushOptionalCachesAsync. Both members // were deleted from IDriver in Gitea #525 as unconsumed surface, and these went with them. Worth // noting one was vacuous anyway: it asserted a footprint of 0 before the flush and 0 after, with // no discovery in between, so it would have passed against a flush that did nothing at all. // ---- Driver.OpcUaClient-015: pure-logic paths ---- /// Verifies that MapSeverity thresholds match OPC UA AC Part 9 guidance. [Fact] public void MapSeverity_thresholds_match_opcua_ac_part9_guidance() { // 1-200 Low, 201-500 Medium, 501-800 High, 801-1000 Critical OpcUaClientDriver.MapSeverity(1).ShouldBe(AlarmSeverity.Low); OpcUaClientDriver.MapSeverity(200).ShouldBe(AlarmSeverity.Low); OpcUaClientDriver.MapSeverity(201).ShouldBe(AlarmSeverity.Medium); OpcUaClientDriver.MapSeverity(500).ShouldBe(AlarmSeverity.Medium); OpcUaClientDriver.MapSeverity(501).ShouldBe(AlarmSeverity.High); OpcUaClientDriver.MapSeverity(800).ShouldBe(AlarmSeverity.High); OpcUaClientDriver.MapSeverity(801).ShouldBe(AlarmSeverity.Critical); OpcUaClientDriver.MapSeverity(1000).ShouldBe(AlarmSeverity.Critical); } /// Verifies that driver health starts Unknown and is accessible before init. [Fact] public void Driver_health_starts_Unknown_and_is_accessible_before_init() { // Guards the pre-init health contract for the failed-reconnect-to-Faulted path: // callers that read health before InitializeAsync must see Unknown, not a garbage value. using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-health-015"); var h = drv.GetHealth(); h.State.ShouldBe(DriverState.Unknown); h.LastError.ShouldBeNull(); h.LastSuccessfulRead.ShouldBeNull(); } /// Verifies that driver Session is null before init and RequireSession throws. [Fact] public void Driver_Session_is_null_before_init_so_RequireSession_throws() { // Exercises the early-exit guard in ReadAsync/WriteAsync/DiscoverAsync: they all // call RequireSession() before touching the gate so callers get a clear error. using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-sess-015"); drv.Session.ShouldBeNull(); } /// Verifies that GetHostStatuses returns single entry keyed to configured endpoint. [Fact] public void GetHostStatuses_returns_single_entry_keyed_to_configured_endpoint() { // IHostConnectivityProbe contract: the status list is always non-empty and // keyed by the configured endpoint URL (falls back to EndpointUrl pre-init). var opts = new OpcUaClientDriverOptions { EndpointUrl = "opc.tcp://plc.test:4840" }; using var drv = new OpcUaClientDriver(opts, "opcua-probe-015"); var statuses = drv.GetHostStatuses(); statuses.Count.ShouldBe(1); statuses[0].HostName.ShouldBe("opc.tcp://plc.test:4840"); statuses[0].State.ShouldBe(HostState.Unknown); } // ---- Helper ---- private sealed class CapturingLogger : ILogger { /// Gets the captured log entries. public List<(LogLevel Level, string Message)> Entries { get; } = []; /// Begins a logical scope. /// The type of the state object. /// The state to scope. /// An IDisposable representing the scope. public IDisposable? BeginScope(TState state) where TState : notnull => null; /// Checks if logging is enabled for the specified level. /// The log level. /// True if logging is enabled. public bool IsEnabled(LogLevel logLevel) => true; /// Logs a message. /// The type of the state object. /// The log level. /// The event ID. /// The state. /// The exception. /// The formatter function. public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) { Entries.Add((logLevel, formatter(state, exception))); } } }