test(sdk-matrix): place the fixture's UNS Eq folder in the authority snapshot (#888)
v2-ci / unit-tests (push) Has been cancelled

Since #808 an UNS folder with no placement in the generation is denied (fail
closed), so the matrix's ad hoc "Eq" folder made every UNS case fail with
BadUserAccessDenied on the candidate server. The fixture now supplies
unsFolderScopes (+ the alarm's tagReferencingEquipment) as a deployed
generation does. An unplaced "Unplaced" UNS folder is kept as a negative
control: the Shared client leg requires it denied against a #808+ server with
the ACL on. No server authorization code changed.

Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
This commit is contained in:
Joseph Doherty
2026-11-12 11:45:15 -05:00
parent a83bf1fa27
commit 1e1fad76c4
2 changed files with 30 additions and 4 deletions
@@ -126,7 +126,16 @@ internal static class ProcessHost
var nm = server.NodeManager!; var sink = new SdkAddressSpaceSink(nm);
var authority = new DataPlaneAuthorityState();
authority.Apply("matrix", "rev1", [new ApiKey { ApiKeyId = "matrix", Name = "matrix", SecretHash = "fixture", Principal = "matrix", Enabled = true, CreatedUtc = DateTime.UtcNow.AddDays(-1) }],
[new NodeAcl { NodeAclId = "all", ClusterId = "matrix", Principal = "matrix", ScopeKind = NodeAclScopeKind.Cluster, PermissionFlags = (NodePermissions)int.MaxValue }], new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>());
[new NodeAcl { NodeAclId = "all", ClusterId = "matrix", Principal = "matrix", ScopeKind = NodeAclScopeKind.Cluster, PermissionFlags = (NodePermissions)int.MaxValue }], new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>()
#if SDK2
// lmxopcua#888: since #808 an UNS folder with no placement in the authority snapshot is denied (fail closed, no
// realm/cluster fallback). A real deployment's generation places every Area/Line/Equipment folder; do the same
// for the fixture's "Eq" equipment folder (a root folder, so its id chain is just ["Eq"]). The "Unplaced" folder
// below is deliberately left out of the map: it is the negative control that must stay denied.
, unsFolderScopes: new Dictionary<string, UnsFolderScope> { ["Eq"] = new UnsFolderScope(new NodeAclPath([NodeAclRealms.Uns, "Eq"]), ["Eq"]) },
tagReferencingEquipment: new Dictionary<string, IReadOnlySet<string>> { [Alarm] = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "Eq" } }
#endif
);
nm.DataPlaneAuthority = authority;
// OPCUA_MATRIX_ACL=off mirrors the shipped default (Security:Acl:Enabled=false): no node authorizer.
if (Environment.GetEnvironmentVariable("OPCUA_MATRIX_ACL") != "off")
@@ -134,6 +143,10 @@ internal static class ProcessHost
var history = new Source(Root); nm.HistorianDataSource = history;
sink.EnsureFolder(Folder, null, "dev", AddressSpaceRealm.Raw);
sink.EnsureFolder("Eq", null, "Eq", AddressSpaceRealm.Uns);
// Negative control (#888): an UNS folder + variable the generation does not place must stay denied under the ACL.
sink.EnsureFolder("Unplaced", null, "Unplaced", AddressSpaceRealm.Uns);
sink.EnsureVariable("Unplaced/Scalar", "Unplaced", "Scalar", "Int32", true, AddressSpaceRealm.Uns);
sink.WriteValue("Unplaced/Scalar", 5, 0, Epoch, AddressSpaceRealm.Uns);
foreach (var realm in new[] { AddressSpaceRealm.Raw, AddressSpaceRealm.Uns })
{
var scalar = realm == AddressSpaceRealm.Raw ? Scalar : "Eq/Scalar";
@@ -247,6 +260,18 @@ internal static class ProcessHost
Require(new[] { "Eq/Scalar", "Eq/Vector" }.Select(id => new NodeId(id, uns).ToString()).All(id => unsChildren.Any(child => child.NodeId == id)), "Missing UNS scalar/array children");
});
await Case("initial-quality-explicit-source-and-server-timestamps", async () => { var initial = await client.ReadValueAsync(ScalarId(raw), Ct); Require(StatusCode.IsGood(initial.StatusCode), "Bad initial quality"); Require(SourceTime(initial) == Epoch && ServerTime(initial) > Epoch, "Lost explicit initial timestamps"); Observations.Add(new { sourceTimestamp = SourceTime(initial).ToString("O"), serverTimestamp = ServerTime(initial).ToString("O"), contract = "Source supplied as 2026-10-01T00:00:00Z; server timestamp observed without normalization or historian algorithm changes" }); });
// #888 negative control: the unplaced UNS folder's variable must be denied against a #808+ server with the ACL on
// (a 1.x baseline server predates #808; with OPCUA_MATRIX_ACL=off there is no authorizer, so it reads Good).
await Case("uns-unplaced-folder-denied-or-open-by-acl-mode", async () =>
{
var expectDenied = Environment.GetEnvironmentVariable("OPCUA_MATRIX_ACL") != "off" && (serverSdk?.StartsWith("2.", StringComparison.Ordinal) ?? false);
string outcome;
try { var v = await client.ReadValueAsync(new NodeId("Unplaced/Scalar", uns), Ct); outcome = v.StatusCode.ToString(); }
catch (Exception e) { outcome = e.Message; }
Observations.Add(new { unplacedRead = outcome, expectDenied });
var denied = outcome.Contains("BadUserAccessDenied", StringComparison.Ordinal);
Require(expectDenied ? denied : !denied, $"Unplaced UNS folder read: expected {(expectDenied ? "denied" : "not denied")}, got {outcome}");
});
foreach (var ns in new[] { raw, uns })
{
await Case($"scalar-read-write-ns-{ns}", async () => { Require(Clr(await client.ReadValueAsync(ScalarId(ns), Ct)) is int, "Scalar lost Int32 type"); Require(StatusCode.IsGood(await client.WriteValueAsync(ScalarId(ns), 42, Ct)), "Scalar write failed"); Require((int)Clr(await client.ReadValueAsync(ScalarId(ns), Ct))! == 42, "Scalar round trip failed"); });
@@ -32,9 +32,10 @@ the ordinary compatibility host remains strict SignAndEncrypt. Before each secur
leg the probe's `prepare` step provisions the consumer identity through
ScadaBridge's own certificate code and exports only its public certificate, which
the runner places in the server's trusted store before the server starts.
`OPCUA_MATRIX_ACL=off` disables the server's node-ACL check; with it on, masters
after lmxopcua#808 deny the three UNS cases because this fixture creates the `Eq`
folder without a deployed generation (fixture limitation, see lmxopcua#782). The task key is
`OPCUA_MATRIX_ACL=off` disables the server's node-ACL check. The fixture's `Eq` equipment folder is placed in the
authority snapshot (`unsFolderScopes`, as a deployed generation does since lmxopcua#808, fixed in lmxopcua#888), so
the UNS cases pass with the ACL on and off; an unplaced `Unplaced` UNS folder is kept as a negative control that the
Shared client leg requires to stay denied with the ACL on. The task key is
generated per process pair, issuer/trusted/rejected stores are task-local, and
server public certificates are explicitly copied. Autoaccept remains false.
Never commit an entire output directory: it contains the fixture's private PKI.