Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7RefusalHealthTests.cs
T
Joseph Doherty 081fa693af fix(s7): access-denied is an answer; review round-1 minors (R07:H4 follow-through)
I-1: a PUT/GET-disabled CPU (every read denied) stayed Faulted with
LastSuccessfulRead frozen, so the comms-loss watchdog reconnected and
re-subscribed it every 90 s forever. Both access-denied arms now also record
an answer: Faulted is kept (worst state wins), the stamp advances, and the
sticky write fault still clears only on a real success. Pinned end to end in
Runtime.Tests: the real S7Driver under the real DriverInstanceActor watchdog
is not reconnected across 2x the threshold; the control (every read timing
out) is.

Minors:
- m-1: injectable pass clock (UtcNow); "LastSuccessfulRead advanced" is now
  asserted strictly, including the write-refusal case.
- m-2: the editor uses S7TagShapeRules.IsByteAnchored instead of its own list.
- m-3: a release that races an init publish also cancels the probe loop the
  publish started after the release took its snapshot.
- m-4: a retained poll failing during a lifecycle operation writes no health.
- m-5: a multi-var chunk that fell back memoises its members refused alone.
- m-7: access-denied needs S7.Net's exact wording and no transport fault in
  the chain.
- m-8: sync teardown clears the write-side Degraded owner; a sync Dispose
  racing a running shutdown is tested.

Refs #742
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 15:57:53 -04:00

294 lines
14 KiB
C#

using S7.Net;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
/// <summary>
/// R07:H4 — a deterministic PLC refusal (S7.Net <c>ReadData</c> / <c>WriteData</c> carrying an S7
/// item error such as "object does not exist", not a socket/timeout/framing fault) proves the CPU
/// answered. It is per-tag Bad with a once-per-tag Warning and NO Degraded; it advances
/// <c>LastSuccessfulRead</c>, so a driver whose every tag is refused (wrong DB number) no longer
/// trips the 90 s comms-loss watchdog against a reachable PLC.
/// </summary>
[Trait("Category", "Unit")]
public sealed class S7RefusalHealthTests
{
private const uint BadDeviceFailure = 0x808B0000u;
/// <summary>
/// The shape S7.Net's READ paths really raise for a CPU item error — a BARE
/// <see cref="Exception"/>, live-probed against the snap7 fixture (not a PlcException).
/// </summary>
private static Exception ReadRefusal() => new("Received error from PLC: Object does not exist.");
private static PlcException WriteRefusal() =>
new(ErrorCode.WriteData, new Exception("Received error from PLC: Address out of range."));
private static S7DriverOptions Options() => new()
{
Host = "192.0.2.1",
Timeout = TimeSpan.FromSeconds(5),
Probe = new S7ProbeOptions { Enabled = false },
// Grouping off: one wire request per tag keeps each tag's outcome its own.
SpanReadMaxGapBytes = -1,
RawTags = S7RawTags.Entries(
new S7TagDefinition("Missing", "DB99.DBW0", S7DataType.UInt16),
new S7TagDefinition("Good", "DB1.DBW0", S7DataType.UInt16)),
};
/// <summary>A stepped pass clock, so "LastSuccessfulRead advanced" is asserted STRICTLY.</summary>
private sealed class PassClock
{
public DateTime Now { get; private set; } = new(2026, 10, 22, 12, 0, 0, DateTimeKind.Utc);
/// <summary>Moves the clock one second on and returns the new time.</summary>
public DateTime Step() => Now = Now.AddSeconds(1);
}
private static async Task<(S7Driver Driver, ScriptedS7PlcFactory Factory, CapturingS7Logger Log, PassClock Clock)> StartAsync()
{
var factory = new ScriptedS7PlcFactory();
var log = new CapturingS7Logger();
var clock = new PassClock();
var drv = new S7Driver(Options(), "s7-refusal", factory, log) { UtcNow = () => clock.Now };
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
drv.GetHealth().LastSuccessfulRead.ShouldBe(clock.Now, "init stamps the pass clock");
return (drv, factory, log, clock);
}
/// <summary>A refused tag is Bad, health stays Healthy, LastSuccessfulRead advances, and it warns once.</summary>
[Fact]
public async Task A_read_refusal_is_per_tag_bad_with_no_health_impact_and_one_warning()
{
var ct = TestContext.Current.CancellationToken;
var (drv, factory, log, clock) = await StartAsync();
using var _ = drv;
factory.Read = (addr, _) => addr == "DB99.DBW0" ? throw ReadRefusal() : Task.FromResult<object?>((ushort)7);
for (var pass = 0; pass < 3; pass++)
{
var at = clock.Step();
var r = await drv.ReadAsync(["Missing"], ct);
r[0].StatusCode.ShouldBe(BadDeviceFailure);
drv.GetHealth().State.ShouldBe(DriverState.Healthy, "a refusal is not a comms fault");
drv.GetHealth().LastSuccessfulRead.ShouldBe(at,
"the CPU answered, so the all-refused pass advances LastSuccessfulRead to the pass time");
}
log.Warnings.Count(w => w.Contains("Missing")).ShouldBe(1, "one Warning per refused tag, not one per poll");
// Positive control: the good tag still reads, and nothing reopened the connection.
(await drv.ReadAsync(["Good"], ct))[0].StatusCode.ShouldBe(0u);
factory.Created.Count.ShouldBe(1);
}
/// <summary>
/// A refusal after a comms fault restores Healthy — the CPU is back and answering. Before the
/// fix the refusal latched Degraded with LastSuccessfulRead frozen, which is exactly the
/// watchdog's trip condition.
/// </summary>
[Fact]
public async Task A_refusal_after_a_comms_fault_restores_healthy()
{
var ct = TestContext.Current.CancellationToken;
var (drv, factory, _, clock) = await StartAsync();
using var __ = drv;
// Positive control: a genuine comms fault DOES degrade, and leaves LastSuccessfulRead frozen.
var initStamp = clock.Now;
clock.Step();
factory.Read = (_, _) => throw new TimeoutException("frozen peer");
await drv.ReadAsync(["Missing"], ct);
var degraded = drv.GetHealth();
degraded.State.ShouldBe(DriverState.Degraded);
degraded.LastSuccessfulRead.ShouldBe(initStamp);
var at = clock.Step();
factory.Read = (addr, _) => addr == "DB99.DBW0" ? throw ReadRefusal() : Task.FromResult<object?>((ushort)7);
var r = await drv.ReadAsync(["Missing"], ct);
r[0].StatusCode.ShouldBe(BadDeviceFailure);
var after = drv.GetHealth();
after.State.ShouldBe(DriverState.Healthy);
after.LastSuccessfulRead.ShouldBe(at);
}
/// <summary>The ReadData-wrapped shape of a refusal is classified the same way.</summary>
[Fact]
public async Task A_read_data_wrapped_refusal_is_also_a_refusal()
{
var ct = TestContext.Current.CancellationToken;
var (drv, factory, _, clock) = await StartAsync();
using var __ = drv;
factory.Read = (_, _) => throw new PlcException(ErrorCode.ReadData, ReadRefusal());
(await drv.ReadAsync(["Missing"], ct))[0].StatusCode.ShouldBe(BadDeviceFailure);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
/// <summary>
/// The bare read-path shape of access denied (PUT/GET disabled) is the Faulted config alert.
/// Before, only a PlcException-wrapped "not allowed" was recognised, so a real CPU's read
/// refusal fell through to BadCommunicationError + Degraded.
/// </summary>
[Fact]
public async Task A_bare_access_denied_read_is_the_faulted_config_alert()
{
var ct = TestContext.Current.CancellationToken;
var (drv, factory, _, clock) = await StartAsync();
using var __ = drv;
factory.Read = (_, _) => throw new Exception("Received error from PLC: Accessing object not allowed.");
(await drv.ReadAsync(["Good"], ct))[0].StatusCode.ShouldBe(0x803D0000u); // BadNotSupported
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
drv.GetHealth().LastError!.ShouldContain("PUT/GET");
}
/// <summary>
/// Review I-1: an access-denied CPU (PUT/GET left at its factory default — EVERY read denied) is
/// reachable and answering, so each pass advances LastSuccessfulRead while staying Faulted.
/// Before, the pass stayed Faulted with the stamp frozen — the comms-loss watchdog's trip
/// condition, so the actor reconnected + re-subscribed every 90 s, forever, which can never fix
/// a PUT/GET setting. The control: a comms fault afterwards leaves the stamp frozen.
/// </summary>
[Fact]
public async Task An_all_access_denied_pass_stays_faulted_but_advances_last_successful_read()
{
var ct = TestContext.Current.CancellationToken;
var (drv, factory, _, clock) = await StartAsync();
using var __ = drv;
factory.Read = (_, _) => throw new Exception("Received error from PLC: Accessing object not allowed.");
DateTime last = default;
for (var pass = 0; pass < 2; pass++)
{
last = clock.Step();
await drv.ReadAsync(["Good", "Missing"], ct);
var h = drv.GetHealth();
h.State.ShouldBe(DriverState.Faulted, "the config alert is kept");
h.LastSuccessfulRead.ShouldBe(last, "the CPU answered, so the stamp advances every pass");
}
// Positive control: a real comms loss does NOT advance it (and the sticky Faulted outranks it).
clock.Step();
factory.Read = (_, _) => throw new TimeoutException("frozen peer");
await drv.ReadAsync(["Good"], ct);
drv.GetHealth().LastSuccessfulRead.ShouldBe(last);
}
/// <summary>The write-side access-denied is an answer too, and its sticky Faulted still clears only on a real success.</summary>
[Fact]
public async Task A_write_access_denied_advances_last_successful_read_and_stays_sticky()
{
var ct = TestContext.Current.CancellationToken;
var (drv, factory, _, clock) = await StartAsync();
using var __ = drv;
factory.Write = (_, _, _) => throw new PlcException(ErrorCode.WriteData,
new Exception("Received error from PLC: Accessing object not allowed."));
var at = clock.Step();
(await drv.WriteAsync([new WriteRequest("Good", (ushort)1)], ct))[0].StatusCode.ShouldBe(0x803D0000u);
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
drv.GetHealth().LastSuccessfulRead.ShouldBe(at);
// A clean read leaves the write-side Faulted in place (sticky) …
clock.Step();
await drv.ReadAsync(["Good"], ct);
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
// … and a real write success clears it.
factory.Write = (_, _, _) => Task.CompletedTask;
clock.Step();
await drv.WriteAsync([new WriteRequest("Good", (ushort)1)], ct);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
/// <summary>
/// Review m-7: a transport fault whose text happens to contain "not allowed" reopens (Degraded),
/// and only S7.Net's exact "Accessing object not allowed" is the access-denied alert.
/// </summary>
[Fact]
public async Task Only_the_exact_access_denied_wording_without_a_transport_fault_is_access_denied()
{
var ct = TestContext.Current.CancellationToken;
var (drv, factory, _, clock) = await StartAsync();
using var __ = drv;
clock.Step();
factory.Write = (_, _, _) => throw new PlcException(ErrorCode.WriteData,
new System.IO.IOException("Accessing object not allowed: socket closed by peer"));
(await drv.WriteAsync([new WriteRequest("Good", (ushort)1)], ct))[0].StatusCode
.ShouldNotBe(0x803D0000u, "not the access-denied BadNotSupported");
drv.GetHealth().State.ShouldBe(DriverState.Degraded, "a transport fault is not a config alert");
S7Driver.IsAccessDenied(new PlcException(ErrorCode.WriteData, new Exception("operation not allowed"))).ShouldBeFalse();
S7Driver.IsAccessDenied(new Exception("Received error from PLC: Accessing object not allowed.")).ShouldBeTrue();
}
/// <summary>A ReadData wrapping a socket fault is still a comms fault (Degraded), not a refusal.</summary>
[Fact]
public async Task A_read_data_wrapping_a_socket_fault_still_degrades()
{
var ct = TestContext.Current.CancellationToken;
var (drv, factory, _, clock) = await StartAsync();
using var __ = drv;
factory.Read = (_, _) => throw new PlcException(ErrorCode.ReadData, new System.IO.IOException("connection reset"));
await drv.ReadAsync(["Good"], ct);
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
}
/// <summary>A write refusal is per-tag Bad, counts as answered and leaves health alone.</summary>
[Fact]
public async Task A_write_refusal_is_per_tag_bad_and_counts_as_answered()
{
var ct = TestContext.Current.CancellationToken;
var (drv, factory, log, clock) = await StartAsync();
using var _ = drv;
factory.Write = (addr, _, _) => addr == "DB99.DBW0" ? throw WriteRefusal() : Task.CompletedTask;
// Put the driver into a comms-fault Degraded first; the refusal must lift it.
factory.Read = (_, _) => throw new TimeoutException("frozen peer");
await drv.ReadAsync(["Good"], ct);
var degraded = drv.GetHealth();
degraded.State.ShouldBe(DriverState.Degraded);
for (var i = 0; i < 2; i++)
{
var at = clock.Step();
var w = await drv.WriteAsync([new WriteRequest("Missing", (ushort)1)], ct);
w[0].StatusCode.ShouldBe(BadDeviceFailure);
var h = drv.GetHealth();
h.LastSuccessfulRead.ShouldBe(at, "a write refusal is an answer: the stamp advances strictly");
// The Degraded was written by the READ side (its own message), so it is the read pass's to clear.
h.State.ShouldBe(DriverState.Degraded);
}
var after = drv.GetHealth();
after.LastSuccessfulRead!.Value.ShouldBeGreaterThan(degraded.LastSuccessfulRead!.Value);
log.Warnings.Count(m => m.Contains("Missing")).ShouldBe(1);
}
/// <summary>
/// A good write clears a Degraded the write side wrote. Before the fix a success-only batch
/// never wrote health, so a transient write fault latched Degraded until some read pass ran.
/// </summary>
[Fact]
public async Task A_good_write_clears_a_write_side_degraded()
{
var ct = TestContext.Current.CancellationToken;
var (drv, factory, _, clock) = await StartAsync();
using var _ = drv;
factory.Write = (_, _, _) => throw new System.IO.IOException("connection reset");
var bad = await drv.WriteAsync([new WriteRequest("Good", (ushort)1)], ct);
bad[0].StatusCode.ShouldNotBe(0u);
drv.GetHealth().State.ShouldBe(DriverState.Degraded, "positive control: a comms fault on write degrades");
factory.Write = (_, _, _) => Task.CompletedTask;
var good = await drv.WriteAsync([new WriteRequest("Good", (ushort)2)], ct);
good[0].StatusCode.ShouldBe(0u);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
}