From 982771df9ab5afbd307b4c33bf9a0cddc47d2447 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 29 Apr 2026 16:26:57 -0400 Subject: [PATCH] =?UTF-8?q?PR=205.4=20=E2=80=94=20Write-by-classification?= =?UTF-8?q?=20parity=20scenarios?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both backends route a write through the same path keyed off the attribute's SecurityClassification, so a single write request must produce the same StatusCode on each: - FreeAccess_or_Operate_write_returns_same_StatusCode_on_both_backends picks the first numeric FreeAccess/Operate attribute and writes 0.0. - Configure_class_write_routes_through_secured_path_on_both_backends picks a Configure/Tune attribute, writes through the secured path, asserts StatusCode parity (the test doesn't care whether the write succeeds — only that both backends produce the same outcome). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../WriteByClassificationParityTests.cs | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests/WriteByClassificationParityTests.cs diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests/WriteByClassificationParityTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests/WriteByClassificationParityTests.cs new file mode 100644 index 0000000..0cd9fce --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests/WriteByClassificationParityTests.cs @@ -0,0 +1,71 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.ParityTests; + +/// +/// PR 5.4 — Write-by-classification parity. Each driver routes writes by the +/// attribute's : FreeAccess / +/// Operate use plain Write; Tune / Configure / +/// VerifiedWrite use WriteSecured. Both backends must surface the +/// same StatusCode for the same write request — successful for FreeAccess / +/// Operate (assuming the dev Galaxy has at least one writable attribute) and +/// failure for Configure when no auth principal is supplied. +/// +[Trait("Category", "ParityE2E")] +[Collection(nameof(ParityCollection))] +public sealed class WriteByClassificationParityTests +{ + private readonly ParityHarness _h; + public WriteByClassificationParityTests(ParityHarness h) => _h = h; + + [Fact] + public async Task FreeAccess_or_Operate_write_returns_same_StatusCode_on_both_backends() + { + _h.RequireBoth(); + + var b = new RecordingAddressSpaceBuilder(); + await ((ITagDiscovery)_h.LegacyDriver!).DiscoverAsync(b, CancellationToken.None); + + var target = b.Variables.FirstOrDefault(v => + v.AttributeInfo.SecurityClass is SecurityClassification.FreeAccess or SecurityClassification.Operate + && v.AttributeInfo.DriverDataType is DriverDataType.Float32 or DriverDataType.Float64 or DriverDataType.Int32); + if (target is null) Assert.Skip("no FreeAccess/Operate numeric writable attribute on dev Galaxy"); + + var request = new[] { new WriteRequest(target.AttributeInfo.FullName, 0.0) }; + var results = await _h.RunOnAvailableAsync( + (driver, ct) => ((IWritable)driver).WriteAsync(request, ct), + CancellationToken.None); + + results[ParityHarness.Backend.LegacyHost][0].StatusCode + .ShouldBe(results[ParityHarness.Backend.MxGateway][0].StatusCode, + $"FreeAccess/Operate StatusCode parity for '{target.AttributeInfo.FullName}'"); + } + + [Fact] + public async Task Configure_class_write_routes_through_secured_path_on_both_backends() + { + _h.RequireBoth(); + + var b = new RecordingAddressSpaceBuilder(); + await ((ITagDiscovery)_h.LegacyDriver!).DiscoverAsync(b, CancellationToken.None); + + var target = b.Variables.FirstOrDefault(v => + v.AttributeInfo.SecurityClass is SecurityClassification.Configure or SecurityClassification.Tune); + if (target is null) Assert.Skip("no Configure/Tune attribute on dev Galaxy"); + + var request = new[] { new WriteRequest(target.AttributeInfo.FullName, 0.0) }; + var results = await _h.RunOnAvailableAsync( + (driver, ct) => ((IWritable)driver).WriteAsync(request, ct), + CancellationToken.None); + + // Both backends route through the secured-write path. The exact StatusCode + // depends on whether the running test identity has write permission on the + // dev Galaxy — what matters here is that they agree, not which value they + // produce. (Parity, not policy.) + results[ParityHarness.Backend.LegacyHost][0].StatusCode + .ShouldBe(results[ParityHarness.Backend.MxGateway][0].StatusCode, + $"Secured-write StatusCode parity for '{target.AttributeInfo.FullName}'"); + } +}