167a48bffa
#587 — a client Acknowledge/Confirm/AddComment/Shelve/Enable/Disable emitted no condition event: the SDK's ReportStateChange is gated on AutoReportStateChanges (never set), and the engine re-projection found the node already in the new state and was suppressed by the delta-gate. WireMethodTransitionReporting now wraps each method's SDK handler and, on Good, reports exactly once via ReportConditionEvent, under Lock so the routed re-projection cannot race in ahead of the SDK applying the state. Independent of the SDK's imprecise AreEventsMonitored bookkeeping. The shelve methods' state is applied by the wrapper (the SDK never applies it). Native conditions' Confirmed/Shelving are method-owned (NativeAlarmProjector's constants would re-report every ack and undo every shelve). MaxTimeShelved set to int.MaxValue ms at materialise — Create() left it 0 and every TimedShelve failed BadShelvingTimeOutOfRange. Wrong "SDK auto-fires (E2)" comments fixed. #588 — with ACL on, GeneralModelChange and AuditWriteUpdate events no longer go to Server.ReportEvent (ungated to every i=2253 item): ReportToServerObject delivers them per item, a model change narrowed to the Affected nodes the session may Browse (dropped when none), an audit write only with Read on its source plus the SDK audit/encryption guard. Flag off: unchanged. #586 review: materialise raises only the new condition's monitored unit (was a full O(N) reconcile per condition ⇒ O(N²) deploys under Lock). Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
202 lines
11 KiB
C#
202 lines
11 KiB
C#
using Opc.Ua;
|
|
using Opc.Ua.Client;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using static ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.ServerEventRig;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
|
|
|
|
/// <summary>
|
|
/// Gitea #588 over the wire — with <c>Security:Acl:Enabled</c> on, the server-wide events this node manager
|
|
/// raises (<c>GeneralModelChangeEvent</c>s, <c>AuditWriteUpdateEvent</c>s) reach a Server-object
|
|
/// (<c>i=2253</c>) item only when its session may see the node the event names.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// They used to go straight to <c>Server.ReportEvent</c> and the core node manager's Server object, which
|
|
/// queues to every all-events item with no ACL decision — so a session with no grant learned, from a
|
|
/// model-change event, the RawPath of every node added, deleted or retyped under it.
|
|
/// </para>
|
|
/// <para>
|
|
/// The withheld session is granted <see cref="ServerEventRig.EqA"/> only, so the absence of the raw
|
|
/// event on its item is paired with a positive control <b>on the same item</b>: the sentinel event about
|
|
/// <see cref="ServerEventRig.EqA"/> is reported after the withheld one, through the same path, and must
|
|
/// arrive.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class AclServerObjectModelChangeTests
|
|
{
|
|
private const string RawVariable = "Plant/Modbus/dev1/Speed";
|
|
|
|
private const NodePermissions See = NodePermissions.Browse | NodePermissions.Read | NodePermissions.Subscribe;
|
|
|
|
/// <summary>
|
|
/// A NodeAdded / NodeDeleted / DataTypeChanged model-change event naming a raw node reaches the
|
|
/// cluster-granted session and not the session granted only an equipment folder — which still receives
|
|
/// the one naming its own folder.
|
|
/// </summary>
|
|
[Theory]
|
|
[InlineData("NodeAdded")]
|
|
[InlineData("NodeDeleted")]
|
|
[InlineData("DataTypeChanged")]
|
|
public async Task A_model_change_naming_a_node_the_session_cannot_browse_is_withheld(string verb)
|
|
{
|
|
var ct = TestContext.Current.CancellationToken;
|
|
var rig = await StartAsync($"AclModelChange{verb}", encrypted: false, ct);
|
|
try
|
|
{
|
|
rig.Enforce(true, [Key()], ClusterGrant(KeyPrincipal, "acl-key", See), EquipmentGrant(EqA, See));
|
|
rig.EnsureEquipment();
|
|
rig.Sink.EnsureFolder(RawDeviceFolder, parentNodeId: null, displayName: "dev1", realm: AddressSpaceRealm.Raw);
|
|
rig.Sink.EnsureVariable(RawVariable, RawDeviceFolder, "Speed", "Int32", writable: false, AddressSpaceRealm.Raw);
|
|
|
|
var (granted, grantedEvents, grantedItem, grantedSubscription) = await ServerObjectAsync(rig, anonymous: false, ct);
|
|
var (narrow, narrowEvents, narrowItem, narrowSubscription) = await ServerObjectAsync(rig, anonymous: true, ct);
|
|
|
|
switch (verb)
|
|
{
|
|
case "NodeAdded": rig.Sink.RaiseNodesAddedModelChange(RawDeviceFolder, AddressSpaceRealm.Raw); break;
|
|
case "NodeDeleted": rig.Sink.RemoveVariableNode(RawVariable, AddressSpaceRealm.Raw).ShouldBeTrue(); break;
|
|
default:
|
|
rig.Sink.UpdateTagAttributes(RawVariable, false, null, "Double", false, null, AddressSpaceRealm.Raw).ShouldBeTrue();
|
|
break;
|
|
}
|
|
|
|
rig.Sink.RaiseNodesAddedModelChange(EqA, AddressSpaceRealm.Uns);
|
|
|
|
await RecordedEvents.WaitUntilAsync(() =>
|
|
grantedEvents.Of(grantedItem).Count(e => NamesEqA(e)) == 1
|
|
&& narrowEvents.Of(narrowItem).Any(NamesEqA));
|
|
|
|
var rawNames = verb == "NodeAdded" ? RawDeviceFolder : RawVariable;
|
|
grantedEvents.Of(grantedItem).Count(e => Names(e, rawNames)).ShouldBe(1, "the granted control receives the raw change");
|
|
narrowEvents.Of(narrowItem).Where(e => Names(e, rawNames)).ShouldBeEmpty(
|
|
"a session that cannot browse the node must not learn its name from a model-change event");
|
|
narrowEvents.Of(narrowItem).Count.ShouldBe(1, "only the change to its own equipment folder");
|
|
|
|
await narrowSubscription.DeleteAsync(true, ct);
|
|
await grantedSubscription.DeleteAsync(true, ct);
|
|
narrow.Dispose();
|
|
granted.Dispose();
|
|
}
|
|
finally { await rig.DisposeAsync(); }
|
|
}
|
|
|
|
/// <summary>
|
|
/// With enforcement off nothing changes: a session holding no grant at all still receives the raw
|
|
/// model-change event through the Server object, exactly once.
|
|
/// </summary>
|
|
/// <remarks>Without this the test above also passes on a build that withholds every model change from everyone.</remarks>
|
|
[Fact]
|
|
public async Task With_acl_enforcement_off_every_server_object_item_receives_the_model_change()
|
|
{
|
|
var ct = TestContext.Current.CancellationToken;
|
|
var rig = await StartAsync("AclModelChangeOff", encrypted: false, ct);
|
|
try
|
|
{
|
|
rig.Enforce(false, [Key()], ClusterGrant(KeyPrincipal, "acl-key", See));
|
|
rig.Sink.EnsureFolder(RawDeviceFolder, parentNodeId: null, displayName: "dev1", realm: AddressSpaceRealm.Raw);
|
|
|
|
var (session, events, item, subscription) = await ServerObjectAsync(rig, anonymous: true, ct);
|
|
|
|
rig.Sink.RaiseNodesAddedModelChange(RawDeviceFolder, AddressSpaceRealm.Raw);
|
|
await RecordedEvents.WaitUntilAsync(() => events.Of(item).Any(e => Names(e, RawDeviceFolder)));
|
|
await Task.Delay(TimeSpan.FromMilliseconds(300), ct);
|
|
events.Of(item).Count(e => Names(e, RawDeviceFolder)).ShouldBe(1);
|
|
|
|
await subscription.DeleteAsync(true, ct);
|
|
session.Dispose();
|
|
}
|
|
finally { await rig.DisposeAsync(); }
|
|
}
|
|
|
|
/// <summary>
|
|
/// With auditing on over an encrypted channel, a rejected device write's <c>AuditWriteUpdateEvent</c> —
|
|
/// which carries the written value and the node's prior one — reaches a session granted <c>Read</c> on
|
|
/// the node and not one granted only an equipment folder.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Auditing is off by default (<c>ServerConfiguration.AuditingEnabled</c>, which this repo never sets),
|
|
/// and audit events only ever leave over SignAndEncrypt — so both sessions are encrypted here, or the
|
|
/// withheld one would be denied by the channel rather than by the ACL and the test would prove nothing.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task An_audit_write_event_reaches_only_sessions_that_may_read_the_node()
|
|
{
|
|
var ct = TestContext.Current.CancellationToken;
|
|
var rig = await StartAsync("AclAuditWrite", encrypted: true, ct);
|
|
try
|
|
{
|
|
rig.Enforce(
|
|
true, [Key()],
|
|
ClusterGrant(KeyPrincipal, "acl-key", See | NodePermissions.WriteOperate), EquipmentGrant(EqA, See));
|
|
rig.SetAuditing(true);
|
|
rig.EnsureEquipment();
|
|
rig.Sink.EnsureFolder(RawDeviceFolder, parentNodeId: null, displayName: "dev1", realm: AddressSpaceRealm.Raw);
|
|
rig.Sink.EnsureVariable(RawVariable, RawDeviceFolder, "Speed", "Int32", writable: true, AddressSpaceRealm.Raw);
|
|
rig.Nm.NodeWriteGateway = new RejectingGateway();
|
|
|
|
var (granted, grantedEvents, grantedItem, grantedSubscription) =
|
|
await ServerObjectAsync(rig, anonymous: false, ct, encrypted: true);
|
|
var (narrow, narrowEvents, narrowItem, narrowSubscription) =
|
|
await ServerObjectAsync(rig, anonymous: true, ct, encrypted: true);
|
|
|
|
var variable = new NodeId(RawVariable, (ushort)granted.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri));
|
|
var write = await granted.WriteAsync(
|
|
null,
|
|
[new WriteValue { NodeId = variable, AttributeId = Attributes.Value, Value = new DataValue(new Variant(42)) }],
|
|
ct);
|
|
StatusCode.IsGood(write.Results[0]).ShouldBeTrue();
|
|
|
|
await RecordedEvents.WaitUntilAsync(() =>
|
|
grantedEvents.HasType(grantedItem, ObjectTypeIds.AuditWriteUpdateEventType));
|
|
|
|
// Reported after the audit event through the same delivery, so it queues behind it on the item.
|
|
rig.Sink.RaiseNodesAddedModelChange(EqA, AddressSpaceRealm.Uns);
|
|
await RecordedEvents.WaitUntilAsync(() => narrowEvents.Of(narrowItem).Any(NamesEqA));
|
|
|
|
narrowEvents.HasType(narrowItem, ObjectTypeIds.AuditWriteUpdateEventType).ShouldBeFalse(
|
|
"a session that may not read the node must not receive its written values through an audit event");
|
|
|
|
await narrowSubscription.DeleteAsync(true, ct);
|
|
await grantedSubscription.DeleteAsync(true, ct);
|
|
narrow.Dispose();
|
|
granted.Dispose();
|
|
}
|
|
finally { await rig.DisposeAsync(); }
|
|
}
|
|
|
|
// ───────────────────────────── helpers ─────────────────────────────
|
|
|
|
private static async Task<(ISession Session, RecordedEvents Events, MonitoredItem Item, Subscription Subscription)>
|
|
ServerObjectAsync(ServerEventRig rig, bool anonymous, CancellationToken ct, bool encrypted = false)
|
|
{
|
|
var session = await rig.ConnectAsync(anonymous, ct, encrypted);
|
|
var events = new RecordedEvents();
|
|
var subscription = await RecordedEvents.SubscribeAsync(session, events, ct);
|
|
var item = RecordedEvents.AddEventItem(subscription, ObjectIds.Server);
|
|
await subscription.ApplyChangesAsync(ct);
|
|
item.Status.Error.ShouldBeNull();
|
|
return (session, events, item, subscription);
|
|
}
|
|
|
|
private static bool NamesEqA(VariantCollection e) => Names(e, EqA);
|
|
|
|
/// <summary>Whether a model-change event's <c>Changes</c> name the node whose identifier is <paramref name="id"/>.</summary>
|
|
private static bool Names(VariantCollection e, string id) =>
|
|
e[RecordedEvents.ChangesField].Value is ExtensionObject[] changes
|
|
&& changes.Any(c => c.Body is ModelChangeStructureDataType { Affected: { } affected }
|
|
&& affected.Identifier as string == id);
|
|
|
|
/// <summary>Every device write fails, so the node manager reverts and audits it.</summary>
|
|
private sealed class RejectingGateway : IOpcUaNodeWriteGateway
|
|
{
|
|
/// <inheritdoc />
|
|
public Task<NodeWriteOutcome> WriteAsync(string nodeId, object? value, AddressSpaceRealm realm, CancellationToken ct) =>
|
|
Task.FromResult(new NodeWriteOutcome(false, "rejected by the test device"));
|
|
}
|
|
}
|