345 lines
13 KiB
C#
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() { }
|
|
}
|
|
}
|
|
}
|