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;
///
/// Gitea #588 over the wire — with Security:Acl:Enabled on, the server-wide events this node manager
/// raises (GeneralModelChangeEvents, AuditWriteUpdateEvents) reach a Server-object
/// (i=2253) item only when its session may see the node the event names.
///
///
///
/// They used to go straight to Server.ReportEvent 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.
///
///
/// The withheld session is granted only, so the absence of the raw
/// event on its item is paired with a positive control on the same item: the sentinel event about
/// is reported after the withheld one, through the same path, and must
/// arrive.
///
///
public sealed class AclServerObjectModelChangeTests
{
private const string RawVariable = "Plant/Modbus/dev1/Speed";
private const NodePermissions See = NodePermissions.Browse | NodePermissions.Read | NodePermissions.Subscribe;
///
/// 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.
///
[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(); }
}
///
/// 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.
///
/// Without this the test above also passes on a build that withholds every model change from everyone.
[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(); }
}
///
/// With auditing on over an encrypted channel, a rejected device write's AuditWriteUpdateEvent —
/// which carries the written value and the node's prior one — reaches a session granted Read on
/// the node and not one granted only an equipment folder.
///
///
/// Auditing is off by default (ServerConfiguration.AuditingEnabled, 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.
///
[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);
/// Whether a model-change event's Changes name the node whose identifier is .
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);
/// Every device write fails, so the node manager reverts and audits it.
private sealed class RejectingGateway : IOpcUaNodeWriteGateway
{
///
public Task WriteAsync(string nodeId, object? value, AddressSpaceRealm realm, CancellationToken ct) =>
Task.FromResult(new NodeWriteOutcome(false, "rejected by the test device"));
}
}