c36224d956
- 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
186 lines
10 KiB
C#
186 lines
10 KiB
C#
using System.Text;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.HardwareGate;
|
|
|
|
/// <summary>
|
|
/// Gitea #592 (hw5) — ONE user-approved native STRING write on a real controller, through the production driver
|
|
/// (<see cref="AbCipDriver"/>, native transport): snapshot twice ≥ 30 s apart (raw bytes, from the exchange tap),
|
|
/// write a distinctive value, read it back, restore the exact original, read back, final raw snapshot equals the
|
|
/// original. Fails closed before ANY write when the tag is not a built-in 88-byte STRING, moved between the two
|
|
/// snapshots, or holds non-zero bytes past its LEN (the driver's encoder zero-fills, so such a value could not be
|
|
/// 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> — 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>
|
|
/// 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 (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 =
|
|
[
|
|
("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;
|
|
|
|
[Fact]
|
|
public async Task A_native_STRING_write_reads_back_and_restores_the_exact_original()
|
|
{
|
|
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 (!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))
|
|
throw new InvalidOperationException($"AB_HW_WRITE_GAP_SECONDS='{rawGap}' must be in [30, 300].");
|
|
|
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(gap + 120));
|
|
var ct = cts.Token;
|
|
const string Ref = "gate-string";
|
|
var diagnostics = DeviceSessionPool.Shared.Diagnostics;
|
|
var replies = new List<byte[]>();
|
|
diagnostics.ExchangeObserver = (req, rep) =>
|
|
{
|
|
// Read Tag Fragmented (0x52) replies only: service 0xD2, status 0, then type 0x02A0 + handle + data.
|
|
if (req.Length > 0 && req.Span[0] == 0x52) lock (replies) replies.Add(rep.ToArray());
|
|
};
|
|
|
|
var drv = new AbCipDriver(new AbCipDriverOptions
|
|
{
|
|
Devices = [new AbCipDeviceOptions(gateway, AbCipPlcFamily.CompactLogix, DeviceName: "hw5-gate")],
|
|
RawTags =
|
|
[
|
|
new RawTagEntry(Ref, $$"""{"tagPath":"{{tagPath}}","dataType":"String","writable":true}""",
|
|
WriteIdempotent: false, DeviceName: gateway),
|
|
],
|
|
Timeout = TimeSpan.FromSeconds(5),
|
|
Transport = AbCipTransport.Native,
|
|
Probe = new AbCipProbeOptions { Enabled = false },
|
|
EnableControllerBrowse = false,
|
|
EnableAlarmProjection = false,
|
|
}, "hw5-string-gate");
|
|
try
|
|
{
|
|
await drv.InitializeAsync("{}", ct);
|
|
Log($"Gateway {gateway}; tag '{tagPath}'; transport Native; snapshot gap {gap} s");
|
|
|
|
var (v1, raw1) = await SnapshotAsync(drv, Ref, replies, "snapshot 1", ct);
|
|
await Task.Delay(TimeSpan.FromSeconds(gap), ct);
|
|
var (v2, raw2) = await SnapshotAsync(drv, Ref, replies, "snapshot 2", ct);
|
|
|
|
// Preflight — fail closed before any write.
|
|
raw1.ShouldBe(raw2, "the tag changed between the two snapshots: something else writes it; NOT writing");
|
|
raw1.Length.ShouldBe(4 + BuiltInStringBytes, "expected a built-in STRING (type 0x02A0 + handle + 88 bytes)");
|
|
var data = raw1.AsSpan(4);
|
|
var len = BitConverter.ToInt32(data[..4]);
|
|
len.ShouldBeInRange(0, StringCapacity);
|
|
data[(4 + len)..].ToArray().ShouldAllBe(b => b == 0,
|
|
"bytes past LEN are not zero, so the driver's zero-filling encoder could not restore them exactly; NOT writing");
|
|
Log($"Preflight OK: type/handle {Convert.ToHexString(raw1.AsSpan(0, 4))}, LEN {len}, tail zero; original '{v1}'");
|
|
if (Environment.GetEnvironmentVariable("AB_HW_WRITE_DRYRUN") == "1")
|
|
{
|
|
Log("AB_HW_WRITE_DRYRUN=1: stopping after the read-only preflight; nothing written.");
|
|
return;
|
|
}
|
|
|
|
var restored = false;
|
|
byte[] rawTest;
|
|
try
|
|
{
|
|
await WriteAsync(drv, Ref, TestValue, "write test value", ct);
|
|
(var vTest, rawTest) = await SnapshotAsync(drv, Ref, replies, "read back test value", ct);
|
|
vTest.ShouldBe(TestValue);
|
|
await WriteAsync(drv, Ref, v1, "restore original", ct);
|
|
restored = true;
|
|
}
|
|
finally
|
|
{
|
|
// Whatever failed after the first write, the original goes back (own token: never skip the restore).
|
|
if (!restored) await WriteAsync(drv, Ref, v1, "restore original (after a failure)", CancellationToken.None);
|
|
}
|
|
var (vRestored, rawRestored) = await SnapshotAsync(drv, Ref, replies, "read back restored", ct);
|
|
vRestored.ShouldBe(v1);
|
|
rawRestored.ShouldBe(raw1, "restored bytes must equal the original byte for byte");
|
|
Log($"Test-value raw LEN {BitConverter.ToInt32(rawTest.AsSpan(4, 4))}; restored raw equals original: {rawRestored.SequenceEqual(raw1)}");
|
|
|
|
await Task.Delay(TimeSpan.FromSeconds(2), ct);
|
|
var (vFinal, rawFinal) = await SnapshotAsync(drv, Ref, replies, "final snapshot", ct);
|
|
rawFinal.ShouldBe(raw1);
|
|
vFinal.ShouldBe(v1);
|
|
Log("RESULT: write, read-back, exact restore — PASS");
|
|
}
|
|
finally
|
|
{
|
|
diagnostics.ExchangeObserver = null;
|
|
await drv.ShutdownAsync(CancellationToken.None);
|
|
}
|
|
}
|
|
|
|
private static async Task<(string Value, byte[] Raw)> SnapshotAsync(
|
|
AbCipDriver drv, string reference, List<byte[]> replies, string label, CancellationToken ct)
|
|
{
|
|
lock (replies) replies.Clear();
|
|
var snap = (await drv.ReadAsync([reference], ct)).Single();
|
|
byte[] reply;
|
|
lock (replies)
|
|
{
|
|
// The first read of a fresh runtime is two wire reads (the create/existence read, then the read itself).
|
|
replies.Count.ShouldBeInRange(1, 2, $"{label}: Read Tag Fragmented replies on the wire");
|
|
replies.ShouldAllBe(r => r.SequenceEqual(replies[0]), $"{label}: the wire reads of one call disagree");
|
|
reply = replies[^1];
|
|
}
|
|
reply[2].ShouldBe((byte)0, $"{label}: CIP general status");
|
|
var raw = reply.AsSpan(4).ToArray(); // type (2) + handle (2) + data
|
|
Log($"{label}: status 0x{snap.StatusCode:X8}, value '{snap.Value}', raw ({raw.Length} B) {Convert.ToHexString(raw)}");
|
|
snap.StatusCode.ShouldBe(0u, label);
|
|
return ((string)snap.Value!, raw);
|
|
}
|
|
|
|
private static async Task WriteAsync(AbCipDriver drv, string reference, string value, string label, CancellationToken ct)
|
|
{
|
|
var result = (await drv.WriteAsync([new WriteRequest(reference, value)], ct)).Single();
|
|
Log($"{label}: '{value}' (ASCII {Convert.ToHexString(Encoding.ASCII.GetBytes(value))}) -> status 0x{result.StatusCode:X8}");
|
|
result.StatusCode.ShouldBe(0u, label);
|
|
}
|
|
|
|
private static void Log(string line) => TestContext.Current.TestOutputHelper?.WriteLine(line);
|
|
}
|