using System.Text.Json; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Shouldly; using Xunit; namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests; /// /// PR-S7-E2 / #303 — connection-level password + protection-level options-binding /// tests. Verifies the no-log invariant on , /// DTO round-trip on the JSON wire form, and the /// dispatch contract that /// uses to send the password right after OpenAsync. The live wire path /// (S7-1500 with a real protection password) is hardware-gated and exercised in /// a separate fixture. /// [Trait("Category", "Unit")] public sealed class S7PasswordOptionsTests { // ---- Defaults ---- [Fact] public void Default_Password_is_null_and_ProtectionLevel_is_Auto() { var opts = new S7DriverOptions(); opts.Password.ShouldBeNull(); opts.ProtectionLevel.ShouldBe(ProtectionLevel.Auto); } // ---- ToString redaction (no-log invariant) ---- [Fact] public void ToString_redacts_Password_when_set() { var opts = new S7DriverOptions { Host = "192.168.1.30", Password = "super-secret-123", ProtectionLevel = ProtectionLevel.Level3, }; var s = opts.ToString(); s.ShouldNotContain("super-secret-123"); s.ShouldContain("***"); s.ShouldContain("Level3"); s.ShouldContain("192.168.1.30"); } [Fact] public void ToString_emits_null_marker_when_Password_is_unset() { var opts = new S7DriverOptions { Host = "192.168.1.30" }; var s = opts.ToString(); s.ShouldContain("Password = "); s.ShouldNotContain("***"); } // ---- DTO round-trip ---- [Fact] public void DTO_round_trip_preserves_Password_and_ProtectionLevel() { var json = """ { "Host": "192.168.1.30", "CpuType": "S71500", "Password": "p@ssw0rd", "ProtectionLevel": "ConnectionMechanism", "Tags": [] } """; var drv = (S7Driver)S7DriverFactoryExtensions.CreateInstance("s7-pwd-dto", json); drv.ShouldNotBeNull(); drv.DriverInstanceId.ShouldBe("s7-pwd-dto"); drv.Dispose(); } [Fact] public void DTO_round_trip_serialise_then_deserialise_preserves_Password_field() { var dto = new S7DriverFactoryExtensions.S7DriverConfigDto { Host = "10.0.0.5", Password = "rotational-secret", ProtectionLevel = "Level2", }; var json = JsonSerializer.Serialize(dto); var back = JsonSerializer.Deserialize(json)!; back.Password.ShouldBe("rotational-secret"); back.ProtectionLevel.ShouldBe("Level2"); } [Fact] public void DTO_explicit_empty_Password_collapses_to_null() { // A typo'd "" Password must NOT try to send an empty password to the PLC. var json = """ { "Host": "192.168.1.30", "Password": "", "Tags": [] } """; // This goes through the factory -> options pipeline and would fail Init if the // empty-string slipped through. The factory is supposed to coerce to null; we // can't easily probe the bound options without InternalsVisibleTo to the test // factory, but we CAN verify the driver constructs successfully and Init-time // is the only place that would observe a non-null empty password. var drv = S7DriverFactoryExtensions.CreateInstance("s7-empty-pwd", json); drv.ShouldNotBeNull(); drv.Dispose(); } [Fact] public void DTO_unknown_ProtectionLevel_is_rejected() { var json = """ { "Host": "192.168.1.30", "ProtectionLevel": "MysteryMode", "Tags": [] } """; Should.Throw(() => S7DriverFactoryExtensions.CreateInstance("s7-bad-level", json)); } [Fact] public void DTO_omitting_Password_and_ProtectionLevel_falls_back_to_defaults() { // Backwards compat: pre-PR-S7-E2 configs must keep loading. var json = """ { "Host": "192.168.1.30", "Tags": [] } """; var drv = S7DriverFactoryExtensions.CreateInstance("s7-legacy", json); drv.ShouldNotBeNull(); drv.Dispose(); } // ---- Auth-gate dispatch contract ---- [Fact] public async Task Password_null_does_not_call_auth_gate() { var fake = new FakeAuthGate(); var opts = new S7DriverOptions { Host = "192.0.2.1", Timeout = TimeSpan.FromMilliseconds(200), // Probe disabled so we don't need an open socket Probe = new S7ProbeOptions { Enabled = false, ProbeAddress = null }, }; using var drv = new S7Driver(opts, "s7-no-pwd") { AuthGate = fake }; // 192.0.2.1 (RFC 5737 TEST-NET-1) is unroutable, so OpenAsync will fail first. await Should.ThrowAsync(async () => await drv.InitializeAsync("{}", TestContext.Current.CancellationToken)); // The point: even if Init failed at OpenAsync, the gate must NEVER have been // invoked because Password was null. fake.CallCount.ShouldBe(0); } [Fact] public async Task Password_set_with_unsupported_gate_logs_warning_and_does_not_throw_at_password_step() { var fake = new FakeAuthGate { Supports = false }; var capturingLogger = new CapturingLogger(); const string secret = "ZZZ-distinct-secret-not-in-log-messages-ZZZ"; var opts = new S7DriverOptions { Host = "192.0.2.1", Timeout = TimeSpan.FromMilliseconds(200), Password = secret, Probe = new S7ProbeOptions { Enabled = false, ProbeAddress = null }, }; // Drive the password code path directly using internals — the unit test seam // exposes Logger / AuthGate. We call the helper via reflection on the driver // method to keep coverage tight without standing up a real PLC. using var drv = new S7Driver(opts, "s7-pwd-no-support") { AuthGate = fake, Logger = capturingLogger, }; // 192.0.2.1 is unroutable, so InitializeAsync will fail at OpenAsync. The // password step is *after* OpenAsync, so we won't actually reach it through // InitializeAsync against a dead host. Instead drive the helper directly via // its reflection seam. var helper = typeof(S7Driver).GetMethod( "TrySendPlcPasswordAsync", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); helper.ShouldNotBeNull(); // Production helper expects a live S7.Net.Plc; pass null since the gate // override means we never dereference it. Method signature accepts Plc + ct. var task = (Task)helper!.Invoke(drv, [null!, TestContext.Current.CancellationToken])!; await task; fake.CallCount.ShouldBe(0); // gate.SupportsSendPassword=false → no call capturingLogger.Entries.ShouldContain(e => e.Level == LogLevel.Warning && e.Message.Contains("does not expose SendPassword")); // No-log invariant — secret value never appears. capturingLogger.Entries.ShouldNotContain(e => e.Message.Contains(secret)); } [Fact] public async Task Password_set_with_supported_gate_invokes_gate_and_logs_success() { var fake = new FakeAuthGate { Supports = true }; var capturingLogger = new CapturingLogger(); var opts = new S7DriverOptions { Host = "192.0.2.1", Password = "rotational-secret", Probe = new S7ProbeOptions { Enabled = false, ProbeAddress = null }, }; using var drv = new S7Driver(opts, "s7-pwd-ok") { AuthGate = fake, Logger = capturingLogger, }; var helper = typeof(S7Driver).GetMethod( "TrySendPlcPasswordAsync", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); var task = (Task)helper!.Invoke(drv, [null!, TestContext.Current.CancellationToken])!; await task; fake.CallCount.ShouldBe(1); fake.LastPassword.ShouldBe("rotational-secret"); capturingLogger.Entries.ShouldContain(e => e.Level == LogLevel.Information && e.Message.Contains("S7 password sent")); // No-log invariant. capturingLogger.Entries.ShouldNotContain(e => e.Message.Contains("rotational-secret")); } [Fact] public async Task Password_send_throwing_propagates_clean_InvalidOperationException() { var fake = new FakeAuthGate { Supports = true, ThrowOnSend = new global::S7.Net.PlcException(global::S7.Net.ErrorCode.WrongCPU_Type), }; var capturingLogger = new CapturingLogger(); var opts = new S7DriverOptions { Host = "192.0.2.1", Password = "wrong-pwd", Probe = new S7ProbeOptions { Enabled = false, ProbeAddress = null }, }; using var drv = new S7Driver(opts, "s7-pwd-bad") { AuthGate = fake, Logger = capturingLogger, }; var helper = typeof(S7Driver).GetMethod( "TrySendPlcPasswordAsync", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); // Direct invoke surfaces TargetInvocationException for synchronous throws; for // an async helper the exception flows through the returned Task, so await it. var task = (Task)helper!.Invoke(drv, [null!, TestContext.Current.CancellationToken])!; var ex = await Should.ThrowAsync(async () => await task); ex.Message.ShouldContain("password authentication failed"); // Inner exception preserved for diagnostics. ex.InnerException.ShouldBeOfType(); // No-log invariant on the exception message itself. ex.Message.ShouldNotContain("wrong-pwd"); } // ---- Reflection probe sanity (production gate against current S7netplus 0.20) ---- [Fact] public void Reflection_gate_against_S7netplus_0_20_reports_unsupported() { // PR-S7-E2 documented limitation: S7netplus 0.20 does not expose SendPassword. // The reflective probe must report SupportsSendPassword=false on a real Plc // instance built against the pinned package. This test pins the limitation — // when S7netplus ships SendPassword in a future minor release, the test breaks // and signals the team to remove the warning path. var plc = new global::S7.Net.Plc(global::S7.Net.CpuType.S71500, "127.0.0.1", 0, 0); var gate = new ReflectionS7PlcAuthGate(plc); gate.SupportsSendPassword.ShouldBeFalse( "S7netplus 0.20 does not expose SendPassword. If this assertion fails, " + "S7netplus has added the API — update docs/v2/s7.md \"PLC password / " + "protection levels\" library-limitation note and remove the warning path."); } // ---- Test doubles ---- private sealed class FakeAuthGate : IS7PlcAuthGate { public bool Supports { get; init; } = true; public bool SupportsSendPassword => Supports; public Exception? ThrowOnSend { get; init; } public int CallCount { get; private set; } public string? LastPassword { get; private set; } public Task TrySendPasswordAsync(string password, CancellationToken cancellationToken) { if (!Supports) return Task.FromResult(false); CallCount++; LastPassword = password; if (ThrowOnSend is not null) throw ThrowOnSend; return Task.FromResult(true); } } private sealed record CapturedLogEntry(LogLevel Level, string Message); private sealed class CapturingLogger : ILogger { public List Entries { get; } = new(); IDisposable? ILogger.BeginScope(TState state) => NullScope.Instance; public bool IsEnabled(LogLevel logLevel) => true; public void Log( LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) { Entries.Add(new CapturedLogEntry(logLevel, formatter(state, exception))); } private sealed class NullScope : IDisposable { public static readonly NullScope Instance = new(); public void Dispose() { } } } }