Merge branch 'master' into b626-626

This commit is contained in:
Joseph Doherty
2026-10-08 15:32:51 -04:00
23 changed files with 1747 additions and 48 deletions
@@ -16,10 +16,24 @@ public sealed class AggregateModeMapperTests
public void Maps_each_aggregate(HistoryAggregateType a, RetrievalMode expected)
=> Assert.Equal(expected, AggregateModeMapper.ToRetrievalMode(a));
[Fact] // matrix guard: a new HistoryAggregateType member must fail here
public void Every_aggregate_member_is_mapped()
[Fact] // matrix guard: every member is either mapped or explicitly reported as not supported
public void Every_aggregate_member_is_mapped_or_reports_not_supported()
{
var original = new[]
{
HistoryAggregateType.Average, HistoryAggregateType.Minimum, HistoryAggregateType.Maximum,
HistoryAggregateType.Total, HistoryAggregateType.Count,
};
foreach (var a in Enum.GetValues<HistoryAggregateType>())
_ = AggregateModeMapper.ToRetrievalMode(a); // must not throw for any defined member
{
if (original.Contains(a))
_ = AggregateModeMapper.ToRetrievalMode(a);
else
Assert.Throws<HistoryAggregateNotSupportedException>(() => AggregateModeMapper.ToRetrievalMode(a));
}
}
[Fact]
public void Undefined_value_is_a_programming_error_not_a_not_supported()
=> Assert.Throws<ArgumentOutOfRangeException>(() => AggregateModeMapper.ToRetrievalMode((HistoryAggregateType)9999));
}
@@ -0,0 +1,131 @@
using System.Diagnostics.Metrics;
using Opc.Ua;
using Opc.Ua.Client;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
/// <summary>
/// #276 — session/publish events are published on the <c>otopcua.driver.opcuaclient.events</c> OTel counter.
/// The listener filters on a UNIQUE <c>driver_instance</c> tag: the Meter is process-static, so a
/// name-only filter would also collect parallel tests' measurements.
/// </summary>
[Trait("Category", "Unit")]
public sealed class OpcUaClientDiagnosticsTests
{
private sealed class Capture : IDisposable
{
private readonly MeterListener _listener = new();
private readonly object _gate = new();
private readonly Dictionary<string, long> _byEvent = new();
public Capture(string instance)
{
_listener.InstrumentPublished = (inst, l) =>
{
if (inst.Name == "otopcua.driver.opcuaclient.events") l.EnableMeasurementEvents(inst);
};
_listener.SetMeasurementEventCallback<long>((_, value, tags, _) =>
{
string? driver = null, evt = null;
foreach (var t in tags)
{
if (t.Key == "driver_instance") driver = t.Value as string;
else if (t.Key == "event") evt = t.Value as string;
}
if (driver != instance || evt is null) return;
lock (_gate) _byEvent[evt] = _byEvent.GetValueOrDefault(evt) + value;
});
_listener.Start();
}
public long this[string evt] { get { lock (_gate) return _byEvent.GetValueOrDefault(evt); } }
public void Dispose() => _listener.Dispose();
}
[Fact]
public void Each_event_lands_on_its_own_tagged_series()
{
var id = $"diag-{Guid.NewGuid():N}";
using var cap = new Capture(id);
var d = new OpcUaClientDiagnostics(id);
d.Record(OpcUaClientDiagnostics.PublishResponse);
d.Record(OpcUaClientDiagnostics.Notification, 3);
d.Record(OpcUaClientDiagnostics.DataChange);
d.Record(OpcUaClientDiagnostics.BadStatusValue);
d.Record(OpcUaClientDiagnostics.PublishError);
d.Record(OpcUaClientDiagnostics.KeepAliveMiss);
d.Record(OpcUaClientDiagnostics.ReconnectAttempt);
d.Record(OpcUaClientDiagnostics.ReconnectSuccess);
d.Record(OpcUaClientDiagnostics.ReconnectExhausted);
cap[OpcUaClientDiagnostics.PublishResponse].ShouldBe(1);
cap[OpcUaClientDiagnostics.Notification].ShouldBe(3);
cap[OpcUaClientDiagnostics.DataChange].ShouldBe(1);
cap[OpcUaClientDiagnostics.BadStatusValue].ShouldBe(1);
cap[OpcUaClientDiagnostics.PublishError].ShouldBe(1);
cap[OpcUaClientDiagnostics.KeepAliveMiss].ShouldBe(1);
cap[OpcUaClientDiagnostics.ReconnectAttempt].ShouldBe(1);
cap[OpcUaClientDiagnostics.ReconnectSuccess].ShouldBe(1);
cap[OpcUaClientDiagnostics.ReconnectExhausted].ShouldBe(1);
}
[Fact]
public void Another_instances_events_are_not_attributed_to_this_one()
{
var mine = $"diag-{Guid.NewGuid():N}";
using var cap = new Capture(mine);
// Positive control: my own event IS seen; the other instance's identical event is not.
new OpcUaClientDiagnostics("someone-else").Record(OpcUaClientDiagnostics.KeepAliveMiss);
new OpcUaClientDiagnostics(mine).Record(OpcUaClientDiagnostics.PublishError);
cap[OpcUaClientDiagnostics.PublishError].ShouldBe(1);
cap[OpcUaClientDiagnostics.KeepAliveMiss].ShouldBe(0);
}
[Fact]
public void Concurrent_records_do_not_lose_updates()
{
var id = $"diag-{Guid.NewGuid():N}";
using var cap = new Capture(id);
var d = new OpcUaClientDiagnostics(id);
const int threads = 8, each = 2_000;
Parallel.For(0, threads, _ =>
{
for (var i = 0; i < each; i++) d.Record(OpcUaClientDiagnostics.DataChange);
});
cap[OpcUaClientDiagnostics.DataChange].ShouldBe(threads * each);
}
[Fact]
public void Zero_or_negative_counts_are_ignored()
{
var id = $"diag-{Guid.NewGuid():N}";
using var cap = new Capture(id);
var d = new OpcUaClientDiagnostics(id);
d.Record(OpcUaClientDiagnostics.Notification, 0);
d.Record(OpcUaClientDiagnostics.Notification, -2);
d.Record(OpcUaClientDiagnostics.DataChange); // positive control
cap[OpcUaClientDiagnostics.Notification].ShouldBe(0);
cap[OpcUaClientDiagnostics.DataChange].ShouldBe(1);
}
/// <summary>
/// #275: the driver does not split Read/Write/CreateMonitoredItems itself because the SDK session already
/// does — <c>Session</c> derives from <c>SessionClientBatched</c> (which batches by the server's
/// OperationLimits, fetched on open). If an SDK bump ever drops that, this goes red and the driver needs
/// its own chunking again.
/// </summary>
[Fact]
public void The_sdk_session_is_the_batching_session_type()
{
typeof(SessionClientBatched).IsAssignableFrom(typeof(Session)).ShouldBeTrue();
}
}
@@ -8,14 +8,23 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
[Trait("Category", "Unit")]
public sealed class OpcUaClientHistoryTests
{
/// <summary>Every <see cref="HistoryAggregateType"/> member (#285: the full Part 13 set, not just five).</summary>
public static IEnumerable<object[]> AllAggregates() =>
Enum.GetValues<HistoryAggregateType>().Select(a => new object[] { a });
/// <summary>Every member maps to a DISTINCT aggregate NodeId (a copy/paste mapping would collide).</summary>
[Fact]
public void MapAggregateToNodeId_is_injective_over_the_whole_enum()
{
var ids = Enum.GetValues<HistoryAggregateType>().Select(OpcUaClientDriver.MapAggregateToNodeId).ToList();
ids.Distinct().Count().ShouldBe(ids.Count);
Enum.GetValues<HistoryAggregateType>().Length.ShouldBeGreaterThan(5);
}
/// <summary>Verifies MapAggregateToNodeId returns standard Part 13 aggregate for every enum.</summary>
/// <param name="agg">The history aggregate type to test.</param>
[Theory]
[InlineData(HistoryAggregateType.Average)]
[InlineData(HistoryAggregateType.Minimum)]
[InlineData(HistoryAggregateType.Maximum)]
[InlineData(HistoryAggregateType.Total)]
[InlineData(HistoryAggregateType.Count)]
[MemberData(nameof(AllAggregates))]
public void MapAggregateToNodeId_returns_standard_Part13_aggregate_for_every_enum(HistoryAggregateType agg)
{
var nodeId = OpcUaClientDriver.MapAggregateToNodeId(agg);
@@ -0,0 +1,216 @@
using Opc.Ua;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
/// <summary>#273 subscription tuning, #277 revocation options, #278 discovery URL.</summary>
[Trait("Category", "Unit")]
public sealed class OpcUaClientTuningAndDiscoveryTests
{
// ---- #273 ----
[Fact]
public void Defaults_equal_the_values_that_used_to_be_hardcoded()
{
var o = new OpcUaClientDriverOptions();
o.SubscriptionKeepAliveCount.ShouldBe(10u);
o.SubscriptionLifetimeCount.ShouldBe(1000u);
o.SubscriptionMaxNotificationsPerPublish.ShouldBe(0u);
o.SubscriptionPriority.ShouldBe((byte)0);
o.SubscriptionPublishingInterval.ShouldBeNull();
o.DiscoveryUrl.ShouldBeNull();
o.CertificateValidation.RejectUnknownRevocationStatus.ShouldBeFalse();
o.CertificateValidation.IssuerStorePath.ShouldBeNull();
Should.NotThrow(() => OpcUaClientDriver.ValidateTuning(o));
}
[Fact]
public void Factory_json_binds_the_new_keys_and_old_json_keeps_defaults()
{
var o = OpcUaClientDriverFactoryExtensions.ParseOptions("d", """
{"subscriptionPublishingInterval":"00:00:02","subscriptionKeepAliveCount":20,
"subscriptionLifetimeCount":600,"subscriptionMaxNotificationsPerPublish":500,
"subscriptionPriority":9, "discoveryUrl":"opc.tcp://lds:4840","discoveryServerUri":"urn:plant:l1",
"certificateValidation":{"rejectUnknownRevocationStatus":true,"issuerStorePath":"/pki/issuers"}}
""");
o.SubscriptionPublishingInterval.ShouldBe(TimeSpan.FromSeconds(2));
o.SubscriptionKeepAliveCount.ShouldBe(20u);
o.SubscriptionLifetimeCount.ShouldBe(600u);
o.SubscriptionMaxNotificationsPerPublish.ShouldBe(500u);
o.SubscriptionPriority.ShouldBe((byte)9);
o.DiscoveryUrl.ShouldBe("opc.tcp://lds:4840");
o.DiscoveryServerUri.ShouldBe("urn:plant:l1");
o.CertificateValidation.RejectUnknownRevocationStatus.ShouldBeTrue();
o.CertificateValidation.IssuerStorePath.ShouldBe("/pki/issuers");
var legacy = OpcUaClientDriverFactoryExtensions.ParseOptions("d", """{"endpointUrl":"opc.tcp://x:1"}""");
legacy.SubscriptionKeepAliveCount.ShouldBe(10u);
legacy.SubscriptionLifetimeCount.ShouldBe(1000u);
}
[Theory]
[InlineData(0u, 1000u)] // keep-alive must be >= 1
[InlineData(10u, 29u)] // lifetime < 3x keep-alive
[InlineData(500u, 1000u)]
public void ValidateTuning_rejects_out_of_range_counts(uint keepAlive, uint lifetime)
{
var o = new OpcUaClientDriverOptions { SubscriptionKeepAliveCount = keepAlive, SubscriptionLifetimeCount = lifetime };
Should.Throw<InvalidOperationException>(() => OpcUaClientDriver.ValidateTuning(o));
}
[Fact]
public void ValidateTuning_accepts_the_3x_boundary_and_rejects_bad_interval_or_discovery_url()
{
Should.NotThrow(() => OpcUaClientDriver.ValidateTuning(
new OpcUaClientDriverOptions { SubscriptionKeepAliveCount = 10, SubscriptionLifetimeCount = 30 }));
Should.Throw<InvalidOperationException>(() => OpcUaClientDriver.ValidateTuning(
new OpcUaClientDriverOptions { SubscriptionPublishingInterval = TimeSpan.Zero }));
Should.Throw<InvalidOperationException>(() => OpcUaClientDriver.ValidateTuning(
new OpcUaClientDriverOptions { DiscoveryUrl = "not a uri" }));
}
[Theory]
[InlineData(null, 1000, 1000)] // no override: the per-call value
[InlineData(null, 10, 50)] // floor still applies to the per-call value
[InlineData(2000, 250, 2000)] // override wins over the per-call value
[InlineData(5, 250, 50)] // ... and is floored too
public void ResolvePublishingIntervalMs_prefers_the_override_and_floors_at_50(int? overrideMs, int requestedMs, int expected)
{
var o = new OpcUaClientDriverOptions
{
SubscriptionPublishingInterval = overrideMs is { } ms ? TimeSpan.FromMilliseconds(ms) : null,
};
OpcUaClientDriver.ResolvePublishingIntervalMs(o, TimeSpan.FromMilliseconds(requestedMs)).ShouldBe(expected);
}
// ---- #277 ----
[Fact]
public void Revocation_failures_are_exactly_Revoked_and_IssuerRevoked()
{
OpcUaClientDriver.IsRevocationFailure(StatusCodes.BadCertificateRevoked).ShouldBeTrue();
OpcUaClientDriver.IsRevocationFailure(StatusCodes.BadCertificateIssuerRevoked).ShouldBeTrue();
// "unknown" is governed by RejectUnknownRevocationStatus, not auto-accept's hard exception;
// untrusted is what auto-accept exists to wave through.
OpcUaClientDriver.IsRevocationFailure(StatusCodes.BadCertificateRevocationUnknown).ShouldBeFalse();
OpcUaClientDriver.IsRevocationFailure(StatusCodes.BadCertificateUntrusted).ShouldBeFalse();
OpcUaClientDriver.IsRevocationFailure(StatusCodes.Good).ShouldBeFalse();
}
// ---- #278 ----
private static EndpointDescription Ep(string url, string policyUri, MessageSecurityMode mode) =>
new() { EndpointUrl = url, SecurityPolicyUri = policyUri, SecurityMode = mode };
[Fact]
public void ResolveEndpointCandidates_without_discovery_is_unchanged()
{
var o = new OpcUaClientDriverOptions { EndpointUrls = ["opc.tcp://a:1", "opc.tcp://b:2"] };
OpcUaClientDriver.ResolveEndpointCandidates(o, []).ShouldBe(["opc.tcp://a:1", "opc.tcp://b:2"]);
OpcUaClientDriver.ResolveEndpointCandidates(o).ShouldBe(["opc.tcp://a:1", "opc.tcp://b:2"]);
}
[Fact]
public void ResolveEndpointCandidates_puts_configured_endpoints_before_discovered_and_dedups()
{
var o = new OpcUaClientDriverOptions { EndpointUrls = ["opc.tcp://Static:1", "opc.tcp://found:2"] };
var merged = OpcUaClientDriver.ResolveEndpointCandidates(o, ["opc.tcp://found:2", "opc.tcp://new:3"]);
merged.ShouldBe(["opc.tcp://Static:1", "opc.tcp://found:2", "opc.tcp://new:3"]);
}
[Fact]
public void ResolveEndpointCandidates_uses_the_single_EndpointUrl_before_discovered_when_no_list()
{
var o = new OpcUaClientDriverOptions { EndpointUrl = "opc.tcp://only:9" };
OpcUaClientDriver.ResolveEndpointCandidates(o, ["opc.tcp://d:1"]).ShouldBe(["opc.tcp://only:9", "opc.tcp://d:1"]);
}
private static OpcUaClientDriver.DiscoveredEndpoint De(string appUri, string url) => new(appUri, url);
[Fact]
public void SelectDiscoveredUrls_with_one_distinct_server_uses_it_and_dedups()
{
var urls = OpcUaClientDriver.SelectDiscoveredUrls(
[De("urn:a", "opc.tcp://a:1"), De("urn:a", "opc.tcp://A:1"), De("urn:a", "opc.tcp://a:2")], null);
urls.ShouldBe(["opc.tcp://a:1", "opc.tcp://a:2"]);
}
[Fact]
public void SelectDiscoveredUrls_with_several_servers_and_no_filter_fails_naming_them()
{
var ex = Should.Throw<InvalidOperationException>(() => OpcUaClientDriver.SelectDiscoveredUrls(
[De("urn:line1", "opc.tcp://l1:1"), De("urn:line2", "opc.tcp://l2:1")], null));
ex.Message.ShouldContain("urn:line1");
ex.Message.ShouldContain("urn:line2");
ex.Message.ShouldContain("DiscoveryServerUri");
}
[Fact]
public void SelectDiscoveredUrls_filter_picks_only_that_server_even_when_several_are_listed()
{
var urls = OpcUaClientDriver.SelectDiscoveredUrls(
[De("urn:line1", "opc.tcp://l1:1"), De("urn:line2", "opc.tcp://l2:1"), De("urn:line2", "opc.tcp://l2:2")],
"urn:line2");
urls.ShouldBe(["opc.tcp://l2:1", "opc.tcp://l2:2"]);
}
[Fact]
public void SelectDiscoveredUrls_filter_matching_nothing_yields_empty_not_another_server()
{
OpcUaClientDriver.SelectDiscoveredUrls([De("urn:line1", "opc.tcp://l1:1")], "urn:other").ShouldBeEmpty();
OpcUaClientDriver.SelectDiscoveredUrls([], null).ShouldBeEmpty();
}
// ---- #273: validation at options load ----
[Theory]
[InlineData("""{"subscriptionKeepAliveCount":0}""")]
[InlineData("""{"subscriptionKeepAliveCount":10,"subscriptionLifetimeCount":29}""")]
[InlineData("""{"discoveryUrl":"not a uri"}""")]
public void ParseOptions_rejects_out_of_range_tuning_with_a_clear_message(string json)
{
var ex = Should.Throw<InvalidOperationException>(
() => OpcUaClientDriverFactoryExtensions.ParseOptions("drv-bad", json));
ex.Message.ShouldContain("drv-bad");
ex.Message.ShouldContain("invalid");
}
[Fact]
public void Factory_CreateInstance_fails_fast_on_a_bad_tuning_config()
{
Should.Throw<InvalidOperationException>(() => OpcUaClientDriverFactoryExtensions.CreateInstance(
"drv-bad", """{"subscriptionLifetimeCount":5}"""));
}
[Fact]
public void FilterEndpointUrls_keeps_only_the_wanted_policy_and_mode()
{
const string b256 = "http://opcfoundation.org/UA/SecurityPolicy#Basic256Sha256";
const string none = "http://opcfoundation.org/UA/SecurityPolicy#None";
var endpoints = new[]
{
Ep("opc.tcp://h:1/none", none, MessageSecurityMode.None),
Ep("opc.tcp://h:1/sign", b256, MessageSecurityMode.Sign),
Ep("opc.tcp://h:1/enc", b256, MessageSecurityMode.SignAndEncrypt),
Ep("", b256, MessageSecurityMode.SignAndEncrypt),
};
OpcUaClientDriver.FilterEndpointUrls(endpoints, b256, MessageSecurityMode.SignAndEncrypt)
.ShouldBe(["opc.tcp://h:1/enc"]);
OpcUaClientDriver.FilterEndpointUrls(endpoints, none, MessageSecurityMode.None)
.ShouldBe(["opc.tcp://h:1/none"]);
}
}
@@ -156,6 +156,49 @@ public sealed class OpcUaClientDriverPageFormSerializationTests
result.ProbeTimeoutSeconds.ShouldBe(25);
}
/// <summary>
/// #273/#275/#277/#278: the typed form rewrites the whole record on save, so a key it does not model
/// is silently dropped (#576). Every new option must survive FromRecord -> ToRecord.
/// </summary>
[Fact]
public void FormModel_RoundTrip_PreservesTuningLimitsRevocationAndDiscovery()
{
var original = new OpcUaClientDriverOptions
{
SubscriptionPublishingInterval = TimeSpan.FromMilliseconds(2500),
SubscriptionKeepAliveCount = 20,
SubscriptionLifetimeCount = 600,
SubscriptionMaxNotificationsPerPublish = 500,
SubscriptionPriority = 9,
CertificateValidation = new OpcUaCertificateValidationOptions
{
RejectUnknownRevocationStatus = true,
IssuerStorePath = "/pki/issuers",
},
DiscoveryUrl = "opc.tcp://lds:4840",
DiscoveryServerUri = "urn:plant:line1",
};
var back = OpcUaClientDriverForm.OpcUaClientFormModel.FromRecord(original).ToRecord([]);
back.SubscriptionPublishingInterval.ShouldBe(TimeSpan.FromMilliseconds(2500));
back.SubscriptionKeepAliveCount.ShouldBe(20u);
back.SubscriptionLifetimeCount.ShouldBe(600u);
back.SubscriptionMaxNotificationsPerPublish.ShouldBe(500u);
back.SubscriptionPriority.ShouldBe((byte)9);
back.DiscoveryServerUri.ShouldBe("urn:plant:line1");
back.CertificateValidation.RejectUnknownRevocationStatus.ShouldBeTrue();
back.CertificateValidation.IssuerStorePath.ShouldBe("/pki/issuers");
back.DiscoveryUrl.ShouldBe("opc.tcp://lds:4840");
// Defaults round-trip as defaults (blank override stays null, not 0 ms).
var def = OpcUaClientDriverForm.OpcUaClientFormModel.FromRecord(new OpcUaClientDriverOptions()).ToRecord([]);
def.SubscriptionPublishingInterval.ShouldBeNull();
def.SubscriptionKeepAliveCount.ShouldBe(10u);
def.SubscriptionLifetimeCount.ShouldBe(1000u);
def.DiscoveryUrl.ShouldBeNull();
}
[Fact]
public void EndpointUrlRow_FromUrl_ToUrl_Trims()
{
@@ -0,0 +1,411 @@
using Opc.Ua;
using Opc.Ua.Client;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
using static ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.ServerEventRig;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
/// <summary>
/// Gitea #629 over the wire — every child of a materialised Part 9 condition (ActiveState/Id,
/// ShelvingState/UnshelveTime, Message, …) carries an instance-scoped NodeId in the condition's own
/// namespace, so a client that translates a child's browse path and then Reads it gets THIS condition's
/// value — not the condition type's ns=0 instance declaration.
/// </summary>
/// <remarks>
/// <para>
/// Before #629 <c>AlarmConditionState.Create(..., assignNodeIds: true)</c> asked the node manager's
/// <c>INodeIdFactory.New</c> for each child's id, and <c>CustomNodeManager2.New</c> returns the node's
/// CURRENT id — the type's instance-declaration id (<c>i=9189</c> for UnshelveTime). Every condition's
/// children therefore shared those ns=0 ids, a translate returned them, and a Read of one was served
/// by the core node manager from the TYPE, never reaching the condition.
/// </para>
/// <para>
/// Each read asserts a value only the instance holds (projected Active, unacked, a distinguishing
/// message, a timed shelve's remaining time), so a type-node answer fails it.
/// </para>
/// </remarks>
public sealed class ConditionChildNodeIdTests
{
private const NodePermissions BrowseRead = NodePermissions.Browse | NodePermissions.Read;
private static readonly string[][] ChildPaths =
[
["ActiveState", "Id"],
["AckedState", "Id"],
["EnabledState", "Id"],
["Severity"],
["Message"],
["Retain"],
["ShelvingState", "UnshelveTime"],
["ShelvingState", "CurrentState", "Id"],
];
/// <summary>
/// Translating a condition child's browse path returns an instance-scoped NodeId in the condition's
/// namespace (derived from the condition id), and a Read of that NodeId returns the instance's value —
/// for a native (raw) and a scripted (UNS) condition, with ACL enforcement on and off.
/// </summary>
[Theory]
[InlineData(true, true)]
[InlineData(false, true)]
[InlineData(true, false)]
[InlineData(false, false)]
public async Task A_condition_child_resolves_to_an_instance_node_that_reads_the_instance_value(bool native, bool aclOn)
{
var ct = TestContext.Current.CancellationToken;
var rig = await StartAsync($"CondChildIds{native}{aclOn}", encrypted: false, ct);
try
{
rig.Enforce(aclOn, [Key()], ClusterGrant(KeyPrincipal, "acl-key", BrowseRead));
rig.MaterialiseNative();
rig.MaterialiseScripted();
ProjectDistinguishingStates(rig);
using var session = await rig.ConnectAsync(anonymous: false, ct);
var condition = ConditionNodeId(session, native);
var conditionId = (string)condition.Identifier;
var resolved = await TranslateAsync(session, condition, ChildPaths, ct);
for (var i = 0; i < ChildPaths.Length; i++)
{
var path = string.Join('/', ChildPaths[i]);
resolved[i].NamespaceIndex.ShouldBe(condition.NamespaceIndex, $"{path} lives in the condition's namespace");
resolved[i].Identifier.ShouldBe($"{conditionId}/{path}", $"{path} is scoped to the condition instance");
}
var values = await ReadAsync(session, resolved, ct);
for (var i = 0; i < values.Count; i++)
StatusCode.IsGood(values[i].StatusCode).ShouldBeTrue($"{string.Join('/', ChildPaths[i])}: {values[i].StatusCode}");
var expectedMessage = native ? "native-distinct" : "scripted-distinct";
values[0].Value.ShouldBe(true, "ActiveState/Id — the instance is active");
values[1].Value.ShouldBe(false, "AckedState/Id — the instance is unacknowledged");
values[2].Value.ShouldBe(true, "EnabledState/Id");
values[3].Value.ShouldBe(
rig.Nm.TryGetAlarmCondition(native ? RawAlarmPath : ScriptedAlarmId, native ? AddressSpaceRealm.Raw : AddressSpaceRealm.Uns)!
.Severity.Value,
"Severity — the instance's mapped severity");
((ushort)values[3].Value).ShouldBeGreaterThan((ushort)0);
(values[4].Value as LocalizedText)?.Text.ShouldBe(expectedMessage, "Message");
values[5].Value.ShouldBe(true, "Retain — an active unacked condition is retained");
if (native)
{
((double)values[6].Value).ShouldBe(0d, "UnshelveTime — a native condition is never shelved");
values[7].Value.ShouldBe(ObjectIds.ShelvedStateMachineType_Unshelved);
}
else
{
((double)values[6].Value).ShouldBeInRange(50_000d, 60_000d, "UnshelveTime — the instance's timed shelve");
values[7].Value.ShouldBe(ObjectIds.ShelvedStateMachineType_TimedShelved);
}
}
finally { await rig.DisposeAsync(); }
}
/// <summary>
/// Two conditions never share a child NodeId, and a browse of a condition lists its children under
/// the same instance ids the translate returns.
/// </summary>
[Fact]
public async Task Children_of_different_conditions_have_distinct_ids_and_browse_agrees_with_translate()
{
var ct = TestContext.Current.CancellationToken;
var rig = await StartAsync("CondChildDistinct", encrypted: false, ct);
try
{
rig.Enforce(false, [Key()], ClusterGrant(KeyPrincipal, "acl-key", BrowseRead));
rig.MaterialiseNative();
rig.MaterialiseScripted();
rig.Sink.MaterialiseAlarmCondition("scripted-eqa-lo", EqA, "lo", "AlarmCondition", 300, AddressSpaceRealm.Uns);
using var session = await rig.ConnectAsync(anonymous: false, ct);
var ns = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.UnsNamespaceUri);
var conditions = new[]
{
ConditionNodeId(session, native: true), ConditionNodeId(session, native: false), new NodeId("scripted-eqa-lo", ns),
};
var all = new List<NodeId>();
foreach (var c in conditions) all.AddRange(await TranslateAsync(session, c, ChildPaths, ct));
all.Distinct().Count().ShouldBe(all.Count, "no two conditions share a child NodeId");
all.ShouldAllBe(id => id.NamespaceIndex != 0, "no child resolves to a type's ns=0 instance declaration");
// Browse the scripted condition's ShelvingState: the forward references are its instance children.
var shelving = (await TranslateAsync(session, conditions[1], [["ShelvingState"]], ct))[0];
shelving.ShouldBe(new NodeId($"{ScriptedAlarmId}/ShelvingState", ns));
var browsed = await BrowseChildrenAsync(session, shelving, ct);
browsed.ShouldContain(new NodeId($"{ScriptedAlarmId}/ShelvingState/UnshelveTime", ns));
browsed.ShouldContain(new NodeId($"{ScriptedAlarmId}/ShelvingState/CurrentState", ns));
browsed.ShouldNotContain(VariableIds.AlarmConditionType_ShelvingState_UnshelveTime);
}
finally { await rig.DisposeAsync(); }
}
/// <summary>
/// With ACL enforcement on, a condition child is governed like its condition: a Read grant on the
/// raw device covers the condition's children (allow), and a grant that does not cover the condition
/// denies them — the child neither falls through to an ungated type node nor to deny-all.
/// </summary>
[Fact]
public async Task A_condition_child_read_is_governed_by_the_conditions_scope()
{
var ct = TestContext.Current.CancellationToken;
var rig = await StartAsync("CondChildAcl", encrypted: false, ct);
try
{
// The key holds Read on the native condition's raw device only; it holds nothing on the scripted
// condition (whose scope is its ScriptedAlarmId, under no granted path).
var deviceGrant = new NodeAcl
{
NodeAclId = "acl-dev1",
ClusterId = ServerEventRig.Cluster,
Principal = KeyPrincipal,
ScopeKind = NodeAclScopeKind.RawDevice,
ScopeId = "dev-1",
PermissionFlags = BrowseRead,
};
rig.Enforce(
true,
[Key()],
new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>
{
[new AclScopeRef(NodeAclScopeKind.RawDevice, "dev-1")] =
[new NodeAclPath([NodeAclRealms.Raw, "Plant", "Modbus", "dev1"])],
},
deviceGrant);
rig.MaterialiseNative();
rig.MaterialiseScripted();
ProjectDistinguishingStates(rig);
using var session = await rig.ConnectAsync(anonymous: false, ct);
var ns = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.UnsNamespaceUri);
var rawNs = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri);
var values = await ReadAsync(session,
[
new NodeId($"{RawAlarmPath}/Message", rawNs),
new NodeId($"{RawAlarmPath}/ActiveState/Id", rawNs),
new NodeId($"{ScriptedAlarmId}/Message", ns),
new NodeId($"{ScriptedAlarmId}/ActiveState/Id", ns),
], ct);
StatusCode.IsGood(values[0].StatusCode).ShouldBeTrue($"granted child read: {values[0].StatusCode}");
(values[0].Value as LocalizedText)?.Text.ShouldBe("native-distinct");
values[1].Value.ShouldBe(true);
values[2].StatusCode.ShouldBe((StatusCode)StatusCodes.BadUserAccessDenied, "ungranted condition's child");
values[3].StatusCode.ShouldBe((StatusCode)StatusCodes.BadUserAccessDenied, "ungranted condition's child");
}
finally { await rig.DisposeAsync(); }
}
/// <summary>
/// Removing a condition removes its children too: once instance-scoped they are reachable by NodeId,
/// so a child left indexed would keep answering a Read with the removed condition's last value.
/// </summary>
[Fact]
public async Task A_removed_conditions_children_are_no_longer_readable()
{
var ct = TestContext.Current.CancellationToken;
var rig = await StartAsync("CondChildRemove", encrypted: false, ct);
try
{
rig.Enforce(false, [Key()], ClusterGrant(KeyPrincipal, "acl-key", BrowseRead));
rig.MaterialiseNative();
rig.MaterialiseScripted();
ProjectDistinguishingStates(rig);
using var session = await rig.ConnectAsync(anonymous: false, ct);
var ns = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.UnsNamespaceUri);
var child = new NodeId($"{ScriptedAlarmId}/Message", ns);
var nested = new NodeId($"{ScriptedAlarmId}/ShelvingState/UnshelveTime", ns);
// Positive control: readable while the condition exists.
var before = await ReadAsync(session, [child, nested], ct);
(before[0].Value as LocalizedText)?.Text.ShouldBe("scripted-distinct");
StatusCode.IsGood(before[1].StatusCode).ShouldBeTrue();
rig.Nm.RemoveAlarmConditionNode(ScriptedAlarmId, AddressSpaceRealm.Uns).ShouldBeTrue();
var after = await ReadAsync(session, [child, nested], ct);
after[0].StatusCode.ShouldBe((StatusCode)StatusCodes.BadNodeIdUnknown);
after[1].StatusCode.ShouldBe((StatusCode)StatusCodes.BadNodeIdUnknown);
// A rebuild forgets them the same way (the native condition's children, here).
var rawNs = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri);
var rawChild = new NodeId($"{RawAlarmPath}/Message", rawNs);
StatusCode.IsGood((await ReadAsync(session, [rawChild], ct))[0].StatusCode).ShouldBeTrue();
rig.Nm.RebuildAddressSpace();
(await ReadAsync(session, [rawChild], ct))[0].StatusCode.ShouldBe((StatusCode)StatusCodes.BadNodeIdUnknown);
}
finally { await rig.DisposeAsync(); }
}
/// <summary>
/// The shelve methods resolve by the condition's OWN child method ids
/// (<c>{condition}/ShelvingState/TimedShelve</c>, <c>…/Unshelve</c>) — the ids a browse of the condition
/// returns — whether the call names the condition or its ShelvingState as the object, and each accepted
/// call reports its one condition event.
/// </summary>
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task Shelve_methods_called_by_their_instance_ids_succeed_and_report(bool objectIsShelvingState)
{
var ct = TestContext.Current.CancellationToken;
var rig = await StartAsync($"CondChildMethodIds{objectIsShelvingState}", encrypted: false, ct);
try
{
rig.Enforce(true, [Key()], ClusterGrant(KeyPrincipal, "acl-key", AllMethods));
rig.MaterialiseScripted();
var current = ConditionMethodEventTests.PreState("TimedShelve", native: false) with { Message = "base" };
ConditionMethodEventTests.WireSimulatedOwners(rig, native: false, () => current, s => current = s);
using var session = await rig.ConnectAsync(anonymous: false, ct);
var events = new RecordedEvents();
var subscription = await RecordedEvents.SubscribeAsync(session, events, ct);
var item = RecordedEvents.AddEventItem(subscription, ObjectIds.Server);
await subscription.ApplyChangesAsync(ct);
rig.Project(false, current);
await RecordedEvents.WaitUntilAsync(() => events.Has(item, "base"));
var condition = ConditionNodeId(session, native: false);
var ns = condition.NamespaceIndex;
var shelvingState = new NodeId($"{ScriptedAlarmId}/ShelvingState", ns);
var target = objectIsShelvingState ? shelvingState : condition;
await session.CallAsync(target, new NodeId($"{ScriptedAlarmId}/ShelvingState/TimedShelve", ns), ct, 60_000d);
await rig.DrainProjectionsAsync();
await RecordedEvents.WaitUntilAsync(() => events.ConditionEvents(item, "base").Count == 2);
events.ConditionEvents(item, "base")[1][RecordedEvents.ShelvingField].Value
.ShouldBe(ObjectIds.ShelvedStateMachineType_TimedShelved);
await session.CallAsync(target, new NodeId($"{ScriptedAlarmId}/ShelvingState/Unshelve", ns), ct);
await rig.DrainProjectionsAsync();
await RecordedEvents.WaitUntilAsync(() => events.ConditionEvents(item, "base").Count == 3);
events.ConditionEvents(item, "base")[2][RecordedEvents.ShelvingField].Value
.ShouldBe(ObjectIds.ShelvedStateMachineType_Unshelved);
await subscription.DeleteAsync(true, ct);
}
finally { await rig.DisposeAsync(); }
}
/// <summary>
/// Pins the #629 trade-off: the shelve methods resolve by the condition's instance ids and by the
/// <c>AlarmConditionType_ShelvingState_*</c> ids every client uses (the positive control), but NOT by
/// the <c>ShelvedStateMachineType_*</c> ids — those were the methods' SDK declarations, replaced by the
/// AlarmConditionType ones when the methods moved off their ns=0 NodeIds. Change this deliberately.
/// </summary>
[Fact]
public async Task A_shelve_call_by_the_state_machine_types_method_id_is_not_supported()
{
var ct = TestContext.Current.CancellationToken;
var rig = await StartAsync("CondChildMethodStateMachineId", encrypted: false, ct);
try
{
rig.Enforce(true, [Key()], ClusterGrant(KeyPrincipal, "acl-key", AllMethods));
rig.MaterialiseScripted();
var current = ConditionMethodEventTests.PreState("OneShotShelve", native: false);
ConditionMethodEventTests.WireSimulatedOwners(rig, native: false, () => current, s => current = s);
rig.Project(false, current);
using var session = await rig.ConnectAsync(anonymous: false, ct);
var condition = ConditionNodeId(session, native: false);
var refused = await Should.ThrowAsync<ServiceResultException>(
session.CallAsync(condition, MethodIds.ShelvedStateMachineType_OneShotShelve, ct));
refused.StatusCode.ShouldBe(StatusCodes.BadMethodInvalid);
// Positive control: the AlarmConditionType declaration id resolves on the same condition.
await session.CallAsync(condition, MethodIds.AlarmConditionType_ShelvingState_OneShotShelve, ct);
}
finally { await rig.DisposeAsync(); }
}
// ───────────────────────────── helpers ─────────────────────────────
private const NodePermissions AllMethods =
NodePermissions.Browse | NodePermissions.Read | NodePermissions.Subscribe | NodePermissions.AlarmRead
| NodePermissions.AlarmAcknowledge | NodePermissions.AlarmConfirm | NodePermissions.AlarmShelve
| NodePermissions.MethodCall;
/// <summary>
/// Native: active, unacked, "native-distinct". Scripted: active, unacked, "scripted-distinct", timed
/// shelved for 60 s — none of which the condition type's instance declarations can hold.
/// </summary>
private static void ProjectDistinguishingStates(ServerEventRig rig)
{
rig.Project(native: true, new AlarmConditionSnapshot(
Active: true, Acknowledged: false, Confirmed: true, Enabled: true,
Shelving: AlarmShelvingKind.Unshelved, Severity: 700, Message: "native-distinct"));
rig.Project(native: false, new AlarmConditionSnapshot(
Active: true, Acknowledged: false, Confirmed: false, Enabled: true,
Shelving: AlarmShelvingKind.Timed, Severity: 300, Message: "scripted-distinct",
UnshelveAtUtc: DateTime.UtcNow.AddSeconds(60)));
}
private static async Task<IReadOnlyList<NodeId>> TranslateAsync(
ISession session, NodeId start, IReadOnlyList<string[]> paths, CancellationToken ct)
{
var browsePaths = new BrowsePathCollection();
foreach (var path in paths)
{
var relative = new RelativePath();
foreach (var name in path)
{
relative.Elements.Add(new RelativePathElement
{
ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences,
IsInverse = false,
IncludeSubtypes = true,
TargetName = new QualifiedName(name),
});
}
browsePaths.Add(new BrowsePath { StartingNode = start, RelativePath = relative });
}
var response = await session.TranslateBrowsePathsToNodeIdsAsync(null, browsePaths, ct);
var results = new List<NodeId>();
for (var i = 0; i < response.Results.Count; i++)
{
var r = response.Results[i];
StatusCode.IsGood(r.StatusCode).ShouldBeTrue($"translate {string.Join('/', paths[i])}: {r.StatusCode}");
r.Targets.Count.ShouldBe(1, $"translate {string.Join('/', paths[i])} has one target");
results.Add(ExpandedNodeId.ToNodeId(r.Targets[0].TargetId, session.NamespaceUris));
}
return results;
}
private static async Task<IReadOnlyList<DataValue>> ReadAsync(ISession session, IReadOnlyList<NodeId> ids, CancellationToken ct)
{
var request = new ReadValueIdCollection(ids.Select(id => new ReadValueId { NodeId = id, AttributeId = Attributes.Value }));
var response = await session.ReadAsync(null, 0, TimestampsToReturn.Neither, request, ct);
return response.Results;
}
private static async Task<IReadOnlyList<NodeId>> BrowseChildrenAsync(ISession session, NodeId node, CancellationToken ct)
{
var request = new BrowseDescriptionCollection
{
new BrowseDescription
{
NodeId = node,
BrowseDirection = BrowseDirection.Forward,
ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences,
IncludeSubtypes = true,
ResultMask = (uint)BrowseResultMask.All,
},
};
var response = await session.BrowseAsync(null, null, 0, request, ct);
return response.Results[0].References
.Select(r => ExpandedNodeId.ToNodeId(r.NodeId, session.NamespaceUris))
.ToList();
}
}
@@ -369,9 +369,8 @@ public sealed class ConditionMethodEventTests
/// Gitea #597: both paths applied the shelve with <c>shelvingTime 0</c>, which the SDK reads as "the
/// longest shelve allowed" — <c>MaxTimeShelved</c>, <c>int.MaxValue</c> ms — so every timed shelve
/// advertised ~24.8 days remaining while the engine expired it on time. Read through the event select
/// clause rather than a Read of the child node: the condition's children carry their type's
/// instance-declaration NodeIds (<c>i=9189</c> for UnshelveTime), so a direct Read reaches the type, not
/// this condition.
/// clause, which is what an alarm client consumes. (A direct Read of the child also works since Gitea
/// #629 gave condition children instance-scoped NodeIds — see <c>ConditionChildNodeIdTests</c>.)
/// </remarks>
[Fact]
public async Task A_scripted_timed_shelve_reports_the_real_remaining_unshelve_time()
@@ -74,10 +74,16 @@ internal sealed class ServerEventRig : IAsyncDisposable
}
/// <summary>Wires the authorizer over the given keys and grants, enforcing or not.</summary>
public DataPlaneAuthorityState Enforce(bool enabled, ApiKey[] keys, params NodeAcl[] grants)
public DataPlaneAuthorityState Enforce(bool enabled, ApiKey[] keys, params NodeAcl[] grants) =>
Enforce(enabled, keys, ScopePaths(), grants);
/// <summary>Wires the authorizer over the given keys, scope paths and grants, enforcing or not.</summary>
public DataPlaneAuthorityState Enforce(
bool enabled, ApiKey[] keys, IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> scopePaths,
params NodeAcl[] grants)
{
var state = new DataPlaneAuthorityState();
state.Apply(Cluster, "rev-1", keys, grants, ScopePaths());
state.Apply(Cluster, "rev-1", keys, grants, scopePaths);
Nm.DataPlaneAuthority = state;
Nm.Authorizer = new DataPlaneAuthorizer(
state, new TriePermissionEvaluator(state.Tries), enabled, NullLogger.Instance, TimeProvider.System);
@@ -268,6 +268,52 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
await host.DisposeAsync();
}
/// <summary>#285: a Part 13 aggregate beyond the original five (Interpolative) is forwarded to the
/// backend as its own <see cref="HistoryAggregateType"/> member.</summary>
[Fact]
public async Task Processed_extended_aggregate_dispatches_to_the_source()
{
var (host, server) = await BootAsync();
var nm = server.NodeManager!;
var fake = new RecordingHistorianDataSource { ProcessedResult = Empty() };
nm.HistorianDataSource = fake;
nm.EnsureVariable("eq-1/interp", parentFolderNodeId: null, displayName: "I", dataType: "Float",
writable: false, historianTagname: "WW.Interp", realm: AddressSpaceRealm.Uns);
var nodeId = nm.TryGetVariable("eq-1/interp")!.NodeId;
InvokeHistoryRead(server, nm, new ReadProcessedDetails
{
StartTime = DateTime.UtcNow.AddHours(-1), EndTime = DateTime.UtcNow, ProcessingInterval = 10_000.0,
AggregateType = new NodeIdCollection { ObjectIds.AggregateFunction_Interpolative },
}, nodeId);
fake.LastCall.ShouldBe("Processed");
fake.LastAggregate.ShouldBe(HistoryAggregateType.Interpolative);
await host.DisposeAsync();
}
/// <summary>#285: a backend that cannot serve a mapped aggregate answers BadAggregateNotSupported
/// (not the generic BadHistoryOperationUnsupported a plain throw produces).</summary>
[Fact]
public async Task Processed_backend_unsupported_aggregate_yields_BadAggregateNotSupported()
{
var (host, server) = await BootAsync();
var nm = server.NodeManager!;
nm.HistorianDataSource = new RecordingHistorianDataSource { AggregateNotSupported = true };
nm.EnsureVariable("eq-1/ns", parentFolderNodeId: null, displayName: "N", dataType: "Float",
writable: false, historianTagname: "WW.Ns", realm: AddressSpaceRealm.Uns);
var nodeId = nm.TryGetVariable("eq-1/ns")!.NodeId;
var (_, errors) = InvokeHistoryRead(server, nm, new ReadProcessedDetails
{
StartTime = DateTime.UtcNow.AddHours(-1), EndTime = DateTime.UtcNow, ProcessingInterval = 10_000.0,
AggregateType = new NodeIdCollection { ObjectIds.AggregateFunction_PercentGood },
}, nodeId);
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadAggregateNotSupported);
await host.DisposeAsync();
}
/// <summary>AtTime read: the requested timestamps reach ReadAtTimeAsync in order.</summary>
[Fact]
public async Task AtTime_dispatches_requested_timestamps()
@@ -430,6 +476,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
private sealed class RecordingHistorianDataSource : IHistorianDataSource
{
public bool ThrowOnRead { get; init; }
public bool AggregateNotSupported { get; init; }
public HistorianRead RawResult { get; set; } = new(Array.Empty<DataValueSnapshot>(), null);
public HistorianRead ProcessedResult { get; set; } = new(Array.Empty<DataValueSnapshot>(), null);
public HistorianRead AtTimeResult { get; set; } = new(Array.Empty<DataValueSnapshot>(), null);
@@ -461,6 +508,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
HistoryAggregateType aggregate, CancellationToken cancellationToken)
{
if (ThrowOnRead) throw new InvalidOperationException("backend boom");
if (AggregateNotSupported) throw new HistoryAggregateNotSupportedException(aggregate);
LastCall = "Processed";
LastTagname = fullReference;
LastStart = startUtc;