using System.IO.Compression; using System.Text; using System.Text.Json; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Authorization; using ZB.MOM.WW.OtOpcUa.OpcUaServer; using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; /// /// Parity coverage for / — /// the "parse the artifact JSON once per apply" seam. Every public static entry point on /// is now a thin wrapper over the instance method of the same name, /// so the ONLY thing that must be proved is that the two produce identical output for the same bytes. /// /// /// /// These are not "does the parser work" tests — that is what the eight existing /// DeploymentArtifact*Tests suites are for, and they now exercise the instance path through /// the wrappers. These pin the refactor itself: same bytes ⇒ same output through either door. /// /// /// Record equality is NOT usable for the composition/projection results: their members are /// IReadOnlyList/IReadOnlyDictionary, which records compare by REFERENCE, so two /// separately-produced-but-identical results are never ==. Every assertion here therefore /// compares element-wise (the element types are records with scalar members, which do compare by /// value). /// /// public sealed class ParsedDeploymentArtifactTests { private const string NodeC1 = "c1-1:4053"; private const string NodeC2 = "c2-1:4053"; private const string NodeUnlisted = "stranger:4053"; /// /// A deliberately rich MULTI-CLUSTER artifact: two clusters + their node rows, the full raw chain /// (folder → driver → device → tag group → tags, including a historized tag, an array tag and a /// native-alarm tag), the UNS chain (area → line → equipment) with a tag reference carrying a /// display-name override, scripts joined by both a VirtualTag and a ScriptedAlarm (with a /// {{equip}} reference so the equipment-reference map is genuinely exercised), plus ApiKeys /// and NodeAcls so the data-plane projection has something to scope. /// private const string Artifact = """ { "Clusters": [{"ClusterId":"C1"},{"ClusterId":"C2"}], "Nodes": [ {"NodeId":"c1-1:4053","ClusterId":"C1"}, {"NodeId":"c2-1:4053","ClusterId":"C2"} ], "RawFolders": [ {"RawFolderId":"RF-plant","ClusterId":"C1","ParentRawFolderId":null,"Name":"Plant"}, {"RawFolderId":"RF-line1","ClusterId":"C1","ParentRawFolderId":"RF-plant","Name":"Line1"}, {"RawFolderId":"RF-c2","ClusterId":"C2","ParentRawFolderId":null,"Name":"Site2"} ], "DriverInstances": [ {"DriverInstanceRowId":"11111111-1111-1111-1111-111111111111","DriverInstanceId":"DI-modbus","ClusterId":"C1","RawFolderId":"RF-line1","Name":"ModbusDrv","DriverType":"Modbus","Enabled":true,"DriverConfig":"{\"unitId\":1}","ResilienceConfig":"{\"bulkheadMaxConcurrent\":16}"}, {"DriverInstanceRowId":"22222222-2222-2222-2222-222222222222","DriverInstanceId":"DI-s7","ClusterId":"C2","RawFolderId":"RF-c2","Name":"S7Drv","DriverType":"S7","Enabled":true,"DriverConfig":"{}"} ], "Devices": [ {"DeviceId":"DV-1","DriverInstanceId":"DI-modbus","Name":"dev1","DeviceConfig":"{\"endpoint\":\"10.0.0.1:502\"}"}, {"DeviceId":"DV-2","DriverInstanceId":"DI-s7","Name":"dev2","DeviceConfig":"{}"} ], "TagGroups": [{"TagGroupId":"TG-fast","DeviceId":"DV-1","ParentTagGroupId":null,"Name":"Fast"}], "Tags": [ {"TagId":"TAG-speed","DeviceId":"DV-1","TagGroupId":"TG-fast","Name":"Speed","DataType":"Float","AccessLevel":1,"WriteIdempotent":true,"TagConfig":"{\"isHistorized\":true,\"historianTagname\":\"WW.Speed\"}"}, {"TagId":"TAG-run","DeviceId":"DV-1","TagGroupId":null,"Name":"Run","DataType":"Boolean","AccessLevel":0,"TagConfig":"{}"}, {"TagId":"TAG-profile","DeviceId":"DV-1","TagGroupId":"TG-fast","Name":"Profile","DataType":"Float","AccessLevel":0,"TagConfig":"{\"isArray\":true,\"arrayLength\":16}"}, {"TagId":"TAG-temphi","DeviceId":"DV-1","TagGroupId":null,"Name":"TempHi","DataType":"Boolean","AccessLevel":1,"TagConfig":"{\"alarm\":{\"alarmType\":\"OffNormalAlarm\",\"severity\":700}}"}, {"TagId":"TAG-c2","DeviceId":"DV-2","TagGroupId":null,"Name":"Pressure","DataType":"Float","AccessLevel":0,"TagConfig":"{}"} ], "UnsAreas": [ {"UnsAreaId":"UA-w","ClusterId":"C1","Name":"West"}, {"UnsAreaId":"UA-e","ClusterId":"C2","Name":"East"} ], "UnsLines": [ {"UnsLineId":"UL-1","UnsAreaId":"UA-w","Name":"L1"}, {"UnsLineId":"UL-2","UnsAreaId":"UA-e","Name":"L2"} ], "Equipment": [ {"EquipmentId":"EQ-mixer","UnsLineId":"UL-1","Name":"Mixer","MachineCode":"MX1"}, {"EquipmentId":"EQ-press","UnsLineId":"UL-2","Name":"Press","MachineCode":"PR1"} ], "UnsTagReferences": [ {"UnsTagReferenceId":"REF-1","EquipmentId":"EQ-mixer","TagId":"TAG-speed","DisplayNameOverride":"MixerSpeed"}, {"UnsTagReferenceId":"REF-2","EquipmentId":"EQ-mixer","TagId":"TAG-run","DisplayNameOverride":null} ], "Scripts": [ {"ScriptId":"SCR-vt","SourceCode":"return ctx.GetTag(\"{{equip}}/MixerSpeed\") * 2;"}, {"ScriptId":"SCR-alarm","SourceCode":"return ctx.GetTag(\"{{equip}}/Run\") == false;"} ], "VirtualTags": [ {"VirtualTagId":"VT-1","EquipmentId":"EQ-mixer","Name":"DoubleSpeed","DataType":"Float","ScriptId":"SCR-vt","Historize":true} ], "ScriptedAlarms": [ {"ScriptedAlarmId":"SA-1","EquipmentId":"EQ-mixer","Name":"Stopped","AlarmType":"OffNormalAlarm","Severity":500,"MessageTemplate":"Mixer stopped","PredicateScriptId":"SCR-alarm","HistorizeToAveva":true,"Retain":true,"Enabled":true} ], "ApiKeys": [ {"ApiKeyId":"k1","Name":"key-one","SecretHash":"hash-one","Principal":"p","Enabled":true,"CreatedUtc":"2026-01-01T00:00:00","ExpiresUtc":"2030-06-15T12:00:00"}, {"ApiKeyId":"k2","Name":"key-two","SecretHash":"hash-two","Principal":"anonymous","Enabled":false,"CreatedUtc":"2026-01-01T00:00:00"} ], "NodeAcls": [ {"NodeAclId":"acl-dev","ClusterId":"C1","Principal":"p","ScopeKind":9,"ScopeId":"DV-1","PermissionFlags":2}, {"NodeAclId":"acl-eq","ClusterId":"C1","Principal":"p","ScopeKind":4,"ScopeId":"EQ-mixer","PermissionFlags":16}, {"NodeAclId":"acl-c2","ClusterId":"C2","Principal":"p","ScopeKind":4,"ScopeId":"EQ-press","PermissionFlags":16} ] } """; private static byte[] Blob => Encoding.UTF8.GetBytes(Artifact); [Theory] [InlineData(NodeC1)] [InlineData(NodeC2)] [InlineData(NodeUnlisted)] public void ResolveClusterScope_is_identical_through_the_parsed_artifact(string nodeId) { var blob = Blob; using var parsed = DeploymentArtifact.Open(blob); parsed.ResolveClusterScope(nodeId).ShouldBe(DeploymentArtifact.ResolveClusterScope(blob, nodeId)); } [Fact] public void The_scope_fixture_actually_distinguishes_its_nodes() { // Falsifiability control for the parity theory above: if every node resolved to the same scope, // a memoization cache keyed on nothing at all would still pass it. var blob = Blob; DeploymentArtifact.ResolveClusterScope(blob, NodeC1) .ShouldBe(new ClusterScope(ClusterFilterMode.ScopeTo, "C1")); DeploymentArtifact.ResolveClusterScope(blob, NodeC2) .ShouldBe(new ClusterScope(ClusterFilterMode.ScopeTo, "C2")); DeploymentArtifact.ResolveClusterScope(blob, NodeUnlisted) .ShouldBe(new ClusterScope(ClusterFilterMode.Suppress, null)); } [Fact] public void ParseDriverInstances_unscoped_is_identical_through_the_parsed_artifact() { var blob = Blob; using var parsed = DeploymentArtifact.Open(blob); var expected = DeploymentArtifact.ParseDriverInstances(blob); expected.Count.ShouldBe(2); // sanity: an empty-vs-empty comparison proves nothing parsed.ParseDriverInstances().ShouldBe(expected); } [Theory] [InlineData(NodeC1)] [InlineData(NodeC2)] [InlineData(NodeUnlisted)] public void ParseDriverInstances_scoped_is_identical_through_the_parsed_artifact(string nodeId) { var blob = Blob; using var parsed = DeploymentArtifact.Open(blob); parsed.ParseDriverInstances(nodeId).ShouldBe(DeploymentArtifact.ParseDriverInstances(blob, nodeId)); } [Fact] public void The_scoped_driver_fixture_actually_filters() { var blob = Blob; DeploymentArtifact.ParseDriverInstances(blob, NodeC1) .Select(s => s.DriverInstanceId).ShouldBe(new[] { "DI-modbus" }); DeploymentArtifact.ParseDriverInstances(blob, NodeC2) .Select(s => s.DriverInstanceId).ShouldBe(new[] { "DI-s7" }); DeploymentArtifact.ParseDriverInstances(blob, NodeUnlisted).ShouldBeEmpty(); } [Theory] [InlineData(NodeC1)] [InlineData(NodeC2)] [InlineData(NodeUnlisted)] public void ParseDataPlaneAuthority_is_identical_through_the_parsed_artifact(string nodeId) { var blob = Blob; using var parsed = DeploymentArtifact.Open(blob); AssertSameAuthority(parsed.ParseDataPlaneAuthority(nodeId), DeploymentArtifact.ParseDataPlaneAuthority(blob, nodeId)); } [Fact] public void The_authority_fixture_carries_keys_grants_and_resolved_scope_paths() { // Sanity on the parity theory above: it compares three collections, and three empties compare equal. var projection = DeploymentArtifact.ParseDataPlaneAuthority(Blob, NodeC1); projection.ClusterId.ShouldBe("C1"); projection.Keys.Select(k => k.ApiKeyId).ShouldBe(new[] { "k1", "k2" }); projection.Grants.Select(g => g.NodeAclId).ShouldBe(new[] { "acl-dev", "acl-eq" }); projection.ScopePaths.ShouldNotBeEmpty(); } [Fact] public void ParseComposition_unscoped_is_identical_through_the_parsed_artifact() { var blob = Blob; using var parsed = DeploymentArtifact.Open(blob); var expected = DeploymentArtifact.ParseComposition(blob); expected.RawTags.ShouldNotBeEmpty(); expected.UnsReferenceVariables.ShouldNotBeEmpty(); expected.EquipmentVirtualTags.ShouldNotBeEmpty(); expected.EquipmentScriptedAlarms.ShouldNotBeEmpty(); AssertSameComposition(parsed.ParseComposition(), expected); } [Theory] [InlineData(NodeC1)] [InlineData(NodeC2)] [InlineData(NodeUnlisted)] public void ParseComposition_scoped_is_identical_through_the_parsed_artifact(string nodeId) { var blob = Blob; using var parsed = DeploymentArtifact.Open(blob); AssertSameComposition(parsed.ParseComposition(nodeId), DeploymentArtifact.ParseComposition(blob, nodeId)); } [Fact] public void The_scoped_composition_fixture_actually_filters() { // BuildClusterSets is the piece this refactor de-duplicated (it used to re-parse the blob), so the // scoped composition has to keep filtering rather than degrade to "everything" or "nothing". var blob = Blob; var c1 = DeploymentArtifact.ParseComposition(blob, NodeC1); c1.UnsAreas.Select(a => a.UnsAreaId).ShouldBe(new[] { "UA-w" }); c1.EquipmentNodes.Select(e => e.EquipmentId).ShouldBe(new[] { "EQ-mixer" }); c1.RawTags.Select(t => t.DriverInstanceId).Distinct().ShouldBe(new[] { "DI-modbus" }); c1.UnsReferenceVariables.Select(v => v.EquipmentId).Distinct().ShouldBe(new[] { "EQ-mixer" }); var c2 = DeploymentArtifact.ParseComposition(blob, NodeC2); c2.UnsAreas.Select(a => a.UnsAreaId).ShouldBe(new[] { "UA-e" }); c2.EquipmentNodes.Select(e => e.EquipmentId).ShouldBe(new[] { "EQ-press" }); DeploymentArtifact.ParseComposition(blob, NodeUnlisted).EquipmentNodes.ShouldBeEmpty(); } [Fact] public void The_scoped_composition_inconsistency_callback_fires_identically_through_both_doors() { var blob = Blob; using var parsed = DeploymentArtifact.Open(blob); var viaStatic = new List(); DeploymentArtifact.ParseComposition(blob, NodeC1, viaStatic.Add); var viaInstance = new List(); parsed.ParseComposition(NodeC1, viaInstance.Add); viaInstance.ShouldBe(viaStatic); } [Fact] public void A_second_call_on_the_same_parsed_artifact_returns_equal_results() { // Memoization sanity: ResolveClusterScope is cached per node id, and the cache must not leak the // first caller's answer to a different node — nor stale the same caller's answer. using var parsed = DeploymentArtifact.Open(Blob); var firstC1 = parsed.ResolveClusterScope(NodeC1); var firstC2 = parsed.ResolveClusterScope(NodeC2); parsed.ResolveClusterScope(NodeC1).ShouldBe(firstC1); parsed.ResolveClusterScope(NodeC2).ShouldBe(firstC2); firstC1.ShouldNotBe(firstC2); parsed.ParseDriverInstances().ShouldBe(parsed.ParseDriverInstances()); parsed.ParseDriverInstances(NodeC1).ShouldBe(parsed.ParseDriverInstances(NodeC1)); AssertSameAuthority(parsed.ParseDataPlaneAuthority(NodeC1), parsed.ParseDataPlaneAuthority(NodeC1)); AssertSameComposition(parsed.ParseComposition(), parsed.ParseComposition()); AssertSameComposition(parsed.ParseComposition(NodeC1), parsed.ParseComposition(NodeC1)); } [Fact] public void Results_survive_disposing_the_parsed_artifact() { // Everything returned is fully materialised — no JsonElement is retained, so a caller that disposes // the artifact the moment the apply's parsing is done cannot be handed a use-after-dispose. AddressSpaceComposition composition; DataPlaneAuthorityProjection authority; IReadOnlyList specs; using (var parsed = DeploymentArtifact.Open(Blob)) { composition = parsed.ParseComposition(NodeC1); authority = parsed.ParseDataPlaneAuthority(NodeC1); specs = parsed.ParseDriverInstances(NodeC1); } // Touch every collection AFTER disposal — a retained JsonElement would throw ObjectDisposedException. composition.RawTags.Select(t => t.NodeId).ShouldNotBeEmpty(); composition.UnsReferenceVariables.Select(v => v.NodeId).ShouldNotBeEmpty(); composition.EquipmentVirtualTags.Select(v => v.Expression).ShouldNotBeEmpty(); composition.EquipmentScriptedAlarms.Select(a => a.PredicateSource).ShouldNotBeEmpty(); authority.Keys.Select(k => k.SecretHash).ShouldNotBeEmpty(); authority.ScopePaths.SelectMany(kv => kv.Value).Select(p => p.Segments.Count).ShouldNotBeEmpty(); specs.Select(s => s.DriverConfig).ShouldNotBeEmpty(); } [Fact] public void Disposing_twice_is_harmless() { var parsed = DeploymentArtifact.Open(Blob); parsed.Dispose(); Should.NotThrow(() => parsed.Dispose()); } [Fact] public void A_malformed_blob_still_degrades_leniently_through_every_static_wrapper() { // The statics own the lenient contract (empty list / empty composition / None scope) — Open() itself // throws, because a caller that opens an artifact wants to know the bytes were unusable. var bad = Encoding.UTF8.GetBytes("{ this is not json"); DeploymentArtifact.ParseDriverInstances(bad).ShouldBeEmpty(); DeploymentArtifact.ParseDriverInstances(bad, NodeC1).ShouldBeEmpty(); DeploymentArtifact.ResolveClusterScope(bad, NodeC1).Mode.ShouldBe(ClusterFilterMode.None); DeploymentArtifact.ParseDataPlaneAuthority(bad, NodeC1).Keys.ShouldBeEmpty(); DeploymentArtifact.ParseComposition(bad).EquipmentNodes.ShouldBeEmpty(); DeploymentArtifact.ParseComposition(bad, NodeC1).EquipmentNodes.ShouldBeEmpty(); } [Fact] public void An_empty_blob_still_degrades_leniently_through_every_static_wrapper() { var empty = Array.Empty(); DeploymentArtifact.ParseDriverInstances(empty).ShouldBeEmpty(); DeploymentArtifact.ParseDriverInstances(empty, NodeC1).ShouldBeEmpty(); DeploymentArtifact.ResolveClusterScope(empty, NodeC1).Mode.ShouldBe(ClusterFilterMode.None); DeploymentArtifact.ParseDataPlaneAuthority(empty, NodeC1).ShouldBe(DataPlaneAuthorityProjection.Empty); DeploymentArtifact.ParseComposition(empty).EquipmentNodes.ShouldBeEmpty(); DeploymentArtifact.ParseComposition(empty, NodeC1).EquipmentNodes.ShouldBeEmpty(); } [Fact] public void Open_reads_gzip_compressed_artifacts() { // Task 8: ConfigComposer now gzips the artifact BEFORE hashing it, so every blob that reaches a node // (SQL row, LocalDb cache chunk, gRPC fetch) is gzip. Decompression lives in exactly one place — // Open() — so this parity assertion covers every static wrapper and every consumer at once. var plain = Blob; var gzipped = Gzip(plain); gzipped[0].ShouldBe((byte)0x1F); gzipped[1].ShouldBe((byte)0x8B); gzipped.Length.ShouldBeLessThan(plain.Length); // the whole point using var parsedGzip = DeploymentArtifact.Open(gzipped); using var parsedPlain = DeploymentArtifact.Open(plain); parsedGzip.ResolveClusterScope(NodeC1).ShouldBe(parsedPlain.ResolveClusterScope(NodeC1)); parsedGzip.ParseDriverInstances().ShouldBe(parsedPlain.ParseDriverInstances()); parsedGzip.ParseDriverInstances(NodeC1).ShouldBe(parsedPlain.ParseDriverInstances(NodeC1)); AssertSameAuthority(parsedGzip.ParseDataPlaneAuthority(NodeC1), parsedPlain.ParseDataPlaneAuthority(NodeC1)); AssertSameComposition(parsedGzip.ParseComposition(), parsedPlain.ParseComposition()); AssertSameComposition(parsedGzip.ParseComposition(NodeC1), parsedPlain.ParseComposition(NodeC1)); // …and through the statics, which is what most call sites still use. DeploymentArtifact.ParseDriverInstances(gzipped, NodeC1) .ShouldBe(DeploymentArtifact.ParseDriverInstances(plain, NodeC1)); AssertSameComposition( DeploymentArtifact.ParseComposition(gzipped, NodeC1), DeploymentArtifact.ParseComposition(plain, NodeC1)); } [Fact] public void Open_still_reads_uncompressed_artifacts() { // Backward compatibility: every pre-upgrade Deployment row (and every cached artifact chunk written // before the upgrade) holds raw JSON. A JSON artifact can never start with 0x1F, so the magic sniff // is unambiguous and these bytes take the identical path they always did. using var parsed = DeploymentArtifact.Open(Blob); parsed.ParseDriverInstances().Select(s => s.DriverInstanceId).ShouldBe(new[] { "DI-modbus", "DI-s7" }); parsed.ParseComposition(NodeC1).RawTags.ShouldNotBeEmpty(); } [Fact] public void Static_wrappers_stay_lenient_on_corrupt_gzip() { // Corrupt/truncated gzip must degrade exactly like malformed JSON does — the decompressor's // InvalidDataException is translated to JsonException so the wrappers' existing lenient catch // (and every apply path's failure handling) covers it unchanged. var truncated = Gzip(Blob)[..24]; DeploymentArtifact.ParseDriverInstances(truncated).ShouldBeEmpty(); DeploymentArtifact.ParseDriverInstances(truncated, NodeC1).ShouldBeEmpty(); DeploymentArtifact.ResolveClusterScope(truncated, NodeC1).Mode.ShouldBe(ClusterFilterMode.None); DeploymentArtifact.ParseDataPlaneAuthority(truncated, NodeC1).Keys.ShouldBeEmpty(); DeploymentArtifact.ParseComposition(truncated).EquipmentNodes.ShouldBeEmpty(); DeploymentArtifact.ParseComposition(truncated, NodeC1).EquipmentNodes.ShouldBeEmpty(); // Open() itself is explicit about unusable bytes, same as it is for malformed JSON. Should.Throw(() => DeploymentArtifact.Open(truncated)); } [Fact] public void Decompression_size_guard_refuses_bombs() { // A hostile/corrupt blob must not be able to OOM the node: gzip of a highly repetitive payload // reaches ~1000:1, so a few-hundred-KB artifact row could inflate to hundreds of MB. The production // limit is 512 MB, which no honest artifact approaches — crafting a real 512 MB expansion in a unit // test would burn seconds and gigabytes, so the ceiling is a parameter on the internal Inflate seam // and this test passes a small one. // // It used to lower a mutable DeploymentArtifact.MaxDecompressedBytes static and restore it in a // finally. xUnit runs test CLASSES in parallel, so that window was visible to every sibling class // parsing a >4 KB artifact concurrently — they would fail with "refusing to inflate" for reasons // having nothing to do with them. Do not reintroduce a process-wide mutation here. var payload = new byte[64 * 1024]; // all zeroes: compresses to a few dozen bytes var bomb = Gzip(payload); bomb.Length.ShouldBeLessThan(1024); // the small-in / huge-out shape the guard exists for var ex = Should.Throw(() => DeploymentArtifact.Inflate(bomb, maxBytes: 4096)); ex.Message.ShouldContain("4096"); ex.Message.ShouldContain("refusing to inflate"); // Falsifiability control: the SAME bytes at the production ceiling inflate to the full payload — so // the throw above is the guard firing, not the gzip stream being unreadable. DeploymentArtifact.Inflate(bomb, DeploymentArtifact.MaxDecompressedBytes).Length.ShouldBe(payload.Length); // …and the guard is actually wired into the door every consumer comes through: Open() at the // production ceiling gets past decompression and fails as JSON (64 KB of NULs is not JSON), never // as a bomb. Should.Throw(() => DeploymentArtifact.Open(bomb)) .Message.ShouldNotContain("refusing to inflate"); // The over-limit JsonException lands in the same lenient catch the static wrappers already own — // that leniency is proved on the corrupt-gzip path in Static_wrappers_stay_lenient_on_corrupt_gzip, // which reaches it through the identical `catch (JsonException)` in every wrapper. Asserting it here // with these bytes would be vacuous: 64 KB of NULs comes back empty from a lenient wrapper whether // the guard fired or the JSON parse did. } private static byte[] Gzip(byte[] plain) { using var output = new MemoryStream(); using (var gz = new GZipStream(output, CompressionLevel.Fastest, leaveOpen: true)) { gz.Write(plain, 0, plain.Length); } return output.ToArray(); } private static void AssertSameComposition(AddressSpaceComposition actual, AddressSpaceComposition expected) { actual.UnsAreas.ShouldBe(expected.UnsAreas); actual.UnsLines.ShouldBe(expected.UnsLines); actual.EquipmentNodes.ShouldBe(expected.EquipmentNodes); actual.DriverInstancePlans.ShouldBe(expected.DriverInstancePlans); actual.ScriptedAlarmPlans.ShouldBe(expected.ScriptedAlarmPlans); actual.EquipmentTags.ShouldBe(expected.EquipmentTags); actual.EquipmentVirtualTags.ShouldBe(expected.EquipmentVirtualTags); actual.EquipmentScriptedAlarms.ShouldBe(expected.EquipmentScriptedAlarms); actual.RawContainers.ShouldBe(expected.RawContainers); actual.RawTags.ShouldBe(expected.RawTags); actual.UnsReferenceVariables.ShouldBe(expected.UnsReferenceVariables); } private static void AssertSameAuthority(DataPlaneAuthorityProjection actual, DataPlaneAuthorityProjection expected) { actual.ClusterId.ShouldBe(expected.ClusterId); actual.Keys.Select(Describe).ShouldBe(expected.Keys.Select(Describe)); actual.Grants.Select(Describe).ShouldBe(expected.Grants.Select(Describe)); actual.ScopePaths.Keys.OrderBy(k => k.ToString()).ShouldBe(expected.ScopePaths.Keys.OrderBy(k => k.ToString())); foreach (var (scope, paths) in expected.ScopePaths) { actual.ScopePaths.ShouldContainKey(scope); actual.ScopePaths[scope].Select(p => string.Join('/', p.Segments)) .ShouldBe(paths.Select(p => string.Join('/', p.Segments))); } } // ApiKey / NodeAcl are EF entities (reference equality), so compare their fields as a flat string. private static string Describe(ZB.MOM.WW.OtOpcUa.Configuration.Entities.ApiKey k) => $"{k.ApiKeyId}|{k.Name}|{k.SecretHash}|{k.Principal}|{k.Enabled}|{k.CreatedUtc:O}|{k.ExpiresUtc:O}|{k.Notes}"; private static string Describe(ZB.MOM.WW.OtOpcUa.Configuration.Entities.NodeAcl a) => $"{a.NodeAclId}|{a.ClusterId}|{a.Principal}|{a.ScopeKind}|{a.ScopeId}|{a.PermissionFlags}|{a.Notes}"; }