Files
lmxopcua/tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7PasswordOptionsTests.cs
2026-04-26 10:51:07 -04:00

345 lines
13 KiB
C#

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;
/// <summary>
/// PR-S7-E2 / #303 — connection-level password + protection-level options-binding
/// tests. Verifies the no-log invariant on <see cref="S7DriverOptions.ToString"/>,
/// DTO round-trip on the JSON wire form, and the
/// <see cref="IS7PlcAuthGate"/> dispatch contract that <see cref="S7Driver"/>
/// uses to send the password right after <c>OpenAsync</c>. The live wire path
/// (S7-1500 with a real protection password) is hardware-gated and exercised in
/// a separate fixture.
/// </summary>
[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 = <null>");
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<S7DriverFactoryExtensions.S7DriverConfigDto>(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<InvalidOperationException>(() =>
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<Exception>(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<S7Driver>();
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<S7Driver>();
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<S7Driver>();
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<InvalidOperationException>(async () => await task);
ex.Message.ShouldContain("password authentication failed");
// Inner exception preserved for diagnostics.
ex.InnerException.ShouldBeOfType<global::S7.Net.PlcException>();
// 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<bool> 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<T> : ILogger<T>
{
public List<CapturedLogEntry> Entries { get; } = new();
IDisposable? ILogger.BeginScope<TState>(TState state) => NullScope.Instance;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(
LogLevel logLevel,
EventId eventId,
TState state,
Exception? exception,
Func<TState, Exception?, string> formatter)
{
Entries.Add(new CapturedLogEntry(logLevel, formatter(state, exception)));
}
private sealed class NullScope : IDisposable
{
public static readonly NullScope Instance = new();
public void Dispose() { }
}
}
}