test(abcip): hw5 review fixes — HardwareGate guard, (host, tag) allow-list, differential-run refusal (#592)
- HardwareGateGuardTests: only StringWriteGateTests may name a write path in HardwareGate/; the allow-list is exactly the two Z-35162 (10.209.70.160) STRING tags; the opt-in, the (host, tag) check and the AB_DIFF_ENDPOINT refusal are present. Proven red by a WriteAsync mutation. - StringWriteGateTests: allow-list entries are (host, tag); any other endpoint is refused; skips while AB_DIFF_ENDPOINT is set, like IntegrationWriteGate. - AbCip.md #566: the close happened somewhere in the 16-60 s window; RST vs FIN not captured. Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
@@ -180,7 +180,9 @@ The driver has two interchangeable wire transports, selected by one `DriverConfi
|
||||
`BadCommunicationError` (transport `BadConnection`); a keep-alive that gets it recycles the connection
|
||||
before any request fails. Every other status — tag-level errors, other `0x01` extended codes — is left to
|
||||
the request. **Observed on a real CompactLogix (Z-35162, hw5, 2026-10-09): neither code.** A connection left
|
||||
idle past its 16 s timeout (keep-alive disabled for the test) was closed at the TCP level; the next request
|
||||
idle 60 s, past its 16 s timeout (keep-alive disabled for the test), was closed at the TCP level somewhere in
|
||||
that 16–60 s window — the exact moment, and whether the controller sent RST or FIN, were not captured (no
|
||||
packet capture; EPIPE on the first write only says the close was complete by then); the next request
|
||||
failed on its write in 9 ms (`Broken pipe` → `BadConnection` → `BadCommunicationError`, no CIP or
|
||||
encapsulation status) and the request after it reopened with a fresh Forward Open. That path needs no CIP
|
||||
check — a faulted session always reopens on the next send — and is pinned offline by
|
||||
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.HardwareGate;
|
||||
|
||||
/// <summary>
|
||||
/// Offline guard for the hardware-gate folder — deliberately NOT env-gated, so it runs in every build. The folder sits
|
||||
/// outside the read-only <c>Differential</c> guard because one test in it writes; this pins how far that goes:
|
||||
/// only <see cref="StringWriteGateTests"/> may name a write path, its allow-list is exactly the two approved
|
||||
/// (Z-35162, tag) pairs, and its explicit opt-in and differential-run refusal are present.
|
||||
/// </summary>
|
||||
public sealed class HardwareGateGuardTests
|
||||
{
|
||||
[Fact]
|
||||
public void Only_the_string_write_gate_names_a_write_path()
|
||||
{
|
||||
// Built by concatenation so this file's own text never matches itself.
|
||||
string[] forbidden =
|
||||
[
|
||||
"Write" + "Async", "Encode" + "Value", "WriteBit" + "InDInt", "Write" + "Tag", "Write" + "Request", "AbCip" + "Driver",
|
||||
];
|
||||
var sources = Directory.GetFiles(Path.GetDirectoryName(ThisFile())!, "*.cs");
|
||||
sources.ShouldContain(f => Path.GetFileName(f) == nameof(StringWriteGateTests) + ".cs");
|
||||
sources.ShouldContain(f => Path.GetFileName(f) == nameof(IdleConnectionEvidenceTests) + ".cs");
|
||||
|
||||
var hits = (from f in sources
|
||||
where Path.GetFileName(f) != nameof(StringWriteGateTests) + ".cs"
|
||||
let text = File.ReadAllText(f)
|
||||
from token in forbidden
|
||||
where text.Contains(token, StringComparison.Ordinal)
|
||||
select $"{Path.GetFileName(f)} names {token}").ToList();
|
||||
hits.ShouldBeEmpty();
|
||||
|
||||
// Positive control: the scan does see the one file that is allowed to write.
|
||||
var writer = File.ReadAllText(sources.Single(f => Path.GetFileName(f) == nameof(StringWriteGateTests) + ".cs"));
|
||||
writer.ShouldContain(forbidden[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_write_allow_list_is_exactly_the_two_approved_pairs_on_Z35162()
|
||||
{
|
||||
StringWriteGateTests.Approved.ShouldBe(
|
||||
[
|
||||
("10.209.70.160", "Program:ES03_TemperatureControl._StrTmp"),
|
||||
("10.209.70.160", "Program:ES03_TemperatureControl._Prefix"),
|
||||
]);
|
||||
|
||||
StringWriteGateTests.IsApproved("ab://10.209.70.160/1,0", "Program:ES03_TemperatureControl._StrTmp").ShouldBeTrue();
|
||||
StringWriteGateTests.IsApproved("ab://10.209.70.160:44818/1,0", "Program:ES03_TemperatureControl._Prefix").ShouldBeTrue();
|
||||
// Same tag on any other controller, another tag on Z-35162, or a malformed gateway: refused.
|
||||
StringWriteGateTests.IsApproved("ab://10.209.70.161/1,0", "Program:ES03_TemperatureControl._StrTmp").ShouldBeFalse();
|
||||
StringWriteGateTests.IsApproved("ab://10.100.0.35/1,0", "Program:ES03_TemperatureControl._StrTmp").ShouldBeFalse();
|
||||
StringWriteGateTests.IsApproved("ab://10.209.70.160/1,0", "test2").ShouldBeFalse();
|
||||
StringWriteGateTests.IsApproved("ab://10.209.70.160/1,0", "Program:UN01_Press._Prefix").ShouldBeFalse();
|
||||
StringWriteGateTests.IsApproved("10.209.70.160", "Program:ES03_TemperatureControl._StrTmp").ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_string_write_gate_keeps_its_opt_in_and_refuses_during_a_differential_run()
|
||||
{
|
||||
var text = File.ReadAllText(Path.Combine(Path.GetDirectoryName(ThisFile())!, nameof(StringWriteGateTests) + ".cs"));
|
||||
text.ShouldContain("""GetEnvironmentVariable("AB_HW_WRITE_APPROVED") != "1") Assert.Skip(""");
|
||||
text.ShouldContain("GetEnvironmentVariable(IntegrationWriteGate.DifferentialEndpointVar) is { Length: > 0 })");
|
||||
text.ShouldContain("if (!IsApproved(gateway, tagPath))");
|
||||
IntegrationWriteGate.DifferentialEndpointVar.ShouldBe("AB_DIFF_ENDPOINT");
|
||||
}
|
||||
|
||||
private static string ThisFile([CallerFilePath] string path = "") => path;
|
||||
}
|
||||
+29
-8
@@ -15,24 +15,43 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.HardwareGate;
|
||||
/// restored byte-exact).
|
||||
/// <list type="bullet">
|
||||
/// <item><c>AB_HW_WRITE_GATEWAY</c> — <c>ab://host/1,0</c>; absent ⇒ skip.</item>
|
||||
/// <item><c>AB_HW_WRITE_STRING_TAG</c> — must be one of <see cref="Approved"/> (the brief's allow-list).</item>
|
||||
/// <item><c>AB_HW_WRITE_STRING_TAG</c> — with the gateway's host, must be a pair on <see cref="Approved"/>.</item>
|
||||
/// <item><c>AB_HW_WRITE_APPROVED=1</c> — the explicit opt-in.</item>
|
||||
/// <item><c>AB_HW_WRITE_GAP_SECONDS</c> — snapshot gap, default 30, at least 30.</item>
|
||||
/// <item><c>AB_HW_WRITE_DRYRUN=1</c> — stop after the read-only preflight (no write).</item>
|
||||
/// </list>
|
||||
/// Never set by any other run; <see cref="IntegrationWriteGate"/> (the simulator opt-in) is deliberately not
|
||||
/// Refused (skipped) while <c>AB_DIFF_ENDPOINT</c> is set (a read-only differential run). Never set by any other
|
||||
/// run; <see cref="IntegrationWriteGate"/> (the simulator opt-in) is deliberately not
|
||||
/// what enables this.
|
||||
/// </summary>
|
||||
[Trait("Category", "LiveIntegration")]
|
||||
public sealed class StringWriteGateTests
|
||||
{
|
||||
/// <summary>The only tags this gate may write: logic-, alias-, trend- and Galaxy-unreferenced (L5X census + raw grep).</summary>
|
||||
private static readonly string[] Approved =
|
||||
/// <summary>
|
||||
/// The only (controller, tag) pairs this gate may write: Z-35162's logic-, alias-, trend- and Galaxy-unreferenced
|
||||
/// STRINGs (L5X census + raw grep). Pinned to the host, so the same tag name on another controller is refused.
|
||||
/// <see cref="HardwareGateGuardTests"/> pins this list.
|
||||
/// </summary>
|
||||
internal static readonly (string Host, string Tag)[] Approved =
|
||||
[
|
||||
"Program:ES03_TemperatureControl._StrTmp",
|
||||
"Program:ES03_TemperatureControl._Prefix",
|
||||
("10.209.70.160", "Program:ES03_TemperatureControl._StrTmp"),
|
||||
("10.209.70.160", "Program:ES03_TemperatureControl._Prefix"),
|
||||
];
|
||||
|
||||
/// <summary>The host of an <c>ab://host[:port]/path</c> gateway, or null when it is not one.</summary>
|
||||
internal static string? GatewayHost(string gateway)
|
||||
{
|
||||
if (!gateway.StartsWith("ab://", StringComparison.OrdinalIgnoreCase)) return null;
|
||||
var authority = gateway[5..].Split('/', 2)[0];
|
||||
var host = authority.Split(':', 2)[0];
|
||||
return host.Length == 0 ? null : host;
|
||||
}
|
||||
|
||||
/// <summary>True only for an exact (host, tag) pair on <see cref="Approved"/>.</summary>
|
||||
internal static bool IsApproved(string gateway, string tag) =>
|
||||
GatewayHost(gateway) is { } host
|
||||
&& Approved.Any(a => string.Equals(a.Host, host, StringComparison.Ordinal) && string.Equals(a.Tag, tag, StringComparison.Ordinal));
|
||||
|
||||
private const string TestValue = "OTOPCUA-TEST";
|
||||
private const int BuiltInStringBytes = 88;
|
||||
private const int StringCapacity = 82;
|
||||
@@ -42,10 +61,12 @@ public sealed class StringWriteGateTests
|
||||
{
|
||||
var gateway = Environment.GetEnvironmentVariable("AB_HW_WRITE_GATEWAY")?.Trim();
|
||||
if (string.IsNullOrEmpty(gateway)) Assert.Skip("AB_HW_WRITE_GATEWAY not set.");
|
||||
if (Environment.GetEnvironmentVariable(IntegrationWriteGate.DifferentialEndpointVar) is { Length: > 0 })
|
||||
Assert.Skip($"Write gate refused: {IntegrationWriteGate.DifferentialEndpointVar} is set, so a read-only differential run is in progress.");
|
||||
if (Environment.GetEnvironmentVariable("AB_HW_WRITE_APPROVED") != "1") Assert.Skip("AB_HW_WRITE_APPROVED=1 not set.");
|
||||
var tagPath = Environment.GetEnvironmentVariable("AB_HW_WRITE_STRING_TAG")?.Trim() ?? "";
|
||||
if (!Approved.Contains(tagPath, StringComparer.Ordinal))
|
||||
throw new InvalidOperationException($"AB_HW_WRITE_STRING_TAG='{tagPath}' is not on the approved list.");
|
||||
if (!IsApproved(gateway, tagPath))
|
||||
throw new InvalidOperationException($"('{gateway}', '{tagPath}') is not an approved (controller, tag) pair.");
|
||||
var gap = 30;
|
||||
if (Environment.GetEnvironmentVariable("AB_HW_WRITE_GAP_SECONDS") is { Length: > 0 } rawGap
|
||||
&& (!int.TryParse(rawGap, out gap) || gap < 30 || gap > 300))
|
||||
|
||||
Reference in New Issue
Block a user