231 lines
12 KiB
C#
231 lines
12 KiB
C#
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Opc.Ua;
|
|
using Opc.Ua.Server;
|
|
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.Authorization;
|
|
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
|
|
|
/// <summary>
|
|
/// Review 01 L2, the half the wire tests (<c>StaleMonitoredNodeWireTests</c>) cannot reach: an item the
|
|
/// ACL revalidation pass has STOOD DOWN is on no SDK fan-out list, so the tombstone a removal runs must find
|
|
/// it through the revalidation map. Otherwise it keeps a component-cache reference to the dead node — and
|
|
/// the cache hands that node to the next item created on the NodeId.
|
|
/// </summary>
|
|
public sealed class NodeManagerStaleMonitoredNodeTests : IDisposable
|
|
{
|
|
private const string Cluster = "SITE-A";
|
|
private const string Principal = "svc";
|
|
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
|
|
|
private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-stale-{Guid.NewGuid():N}");
|
|
|
|
/// <summary>A surgical remove tells a stood-down item and drops it from the revalidation map.</summary>
|
|
[Fact]
|
|
public async Task A_surgical_remove_tells_and_forgets_a_stood_down_item()
|
|
{
|
|
var (host, server) = await BootAsync();
|
|
try
|
|
{
|
|
var nm = server.NodeManager!;
|
|
var (item, nodeId) = await StandDownAsync(nm, "Plant/Modbus/dev1/Speed");
|
|
|
|
nm.RemoveVariableNode("Plant/Modbus/dev1/Speed", AddressSpaceRealm.Raw).ShouldBeTrue();
|
|
|
|
item.Statuses.ShouldContain((StatusCode)StatusCodes.BadNodeIdUnknown);
|
|
nm.LiveItemCount.ShouldBe(0, "the item's node is gone; the map must not keep re-authorizing it");
|
|
nm.IsNodeMonitored(nodeId).ShouldBeFalse();
|
|
}
|
|
finally { await host.DisposeAsync(); }
|
|
}
|
|
|
|
/// <summary>A rebuild sweeps every stood-down item.</summary>
|
|
[Fact]
|
|
public async Task A_rebuild_tells_and_forgets_every_stood_down_item()
|
|
{
|
|
var (host, server) = await BootAsync();
|
|
try
|
|
{
|
|
var nm = server.NodeManager!;
|
|
var (item, _) = await StandDownAsync(nm, "Plant/Modbus/dev1/Speed");
|
|
|
|
nm.RebuildAddressSpace();
|
|
nm.CompleteAddressSpaceApply();
|
|
|
|
item.Statuses.ShouldContain((StatusCode)StatusCodes.BadNodeIdUnknown);
|
|
nm.LiveItemCount.ShouldBe(0);
|
|
}
|
|
finally { await host.DisposeAsync(); }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Positive control for the sweep: a stood-down item on a SURVIVING node is left alone by a surgical
|
|
/// remove of a sibling, and the next permitted pass restores it.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task A_stood_down_item_on_a_surviving_node_is_untouched_and_still_restorable()
|
|
{
|
|
var (host, server) = await BootAsync();
|
|
try
|
|
{
|
|
var nm = server.NodeManager!;
|
|
nm.EnsureVariable("Plant/Modbus/dev1/Other", null, "Other", "Float", writable: false, realm: AddressSpaceRealm.Raw);
|
|
var (item, _) = await StandDownAsync(nm, "Plant/Modbus/dev1/Speed");
|
|
|
|
nm.RemoveVariableNode("Plant/Modbus/dev1/Other", AddressSpaceRealm.Raw).ShouldBeTrue();
|
|
|
|
item.Statuses.ShouldNotContain((StatusCode)StatusCodes.BadNodeIdUnknown);
|
|
nm.LiveItemCount.ShouldBe(1);
|
|
|
|
// Grant restored ⇒ the pass re-attaches it (a second stand-down proves the restore happened).
|
|
nm.Authorizer = Authorizer(permits: true);
|
|
nm.RevalidateMonitoredItems();
|
|
nm.Authorizer = Authorizer(permits: false);
|
|
nm.RevalidateMonitoredItems();
|
|
item.Statuses.Count(s => s == StatusCodes.BadUserAccessDenied).ShouldBe(2);
|
|
}
|
|
finally { await host.DisposeAsync(); }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Review fix round 1 (minor): the same-id kind swap (scripted ⇄ native) in <c>MaterialiseAlarmCondition</c>
|
|
/// drops the old condition instance — one more removal path, so it tombstones like the others. Believed
|
|
/// unreachable from a composition (native ids are RawPaths in Raw, scripted ids live in Uns), so it is driven
|
|
/// directly.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task A_condition_kind_swap_tells_and_forgets_items_on_the_dropped_instance()
|
|
{
|
|
var (host, server) = await BootAsync();
|
|
try
|
|
{
|
|
var nm = server.NodeManager!;
|
|
nm.EnsureFolder("eq-1", null, "Equipment", AddressSpaceRealm.Uns);
|
|
nm.MaterialiseAlarmCondition("al-1", "eq-1", "High", "OffNormalAlarm", 500, AddressSpaceRealm.Uns, isNative: false);
|
|
var nodeId = new NodeId("al-1", nm.TryGetFolder("eq-1")!.NodeId.NamespaceIndex);
|
|
var handle = (NodeHandle)nm.GetManagerHandle(nodeId)!;
|
|
handle.ShouldNotBeNull("non-vacuity: the condition is materialised");
|
|
var item = new StubItem(handle, Identity());
|
|
nm.TrackLiveMonitoredItem(handle, item);
|
|
nm.Authorizer = Authorizer(permits: false);
|
|
nm.RevalidateMonitoredItems();
|
|
item.Statuses.ShouldContain((StatusCode)StatusCodes.BadUserAccessDenied, "non-vacuity: stood down");
|
|
|
|
nm.MaterialiseAlarmCondition("al-1", "eq-1", "High", "OffNormalAlarm", 500, AddressSpaceRealm.Uns, isNative: true);
|
|
|
|
item.Statuses.ShouldContain((StatusCode)StatusCodes.BadNodeIdUnknown, "the dropped instance's item is told");
|
|
nm.LiveItemCount.ShouldBe(0);
|
|
((NodeHandle)nm.GetManagerHandle(nodeId)!).ShouldNotBeNull("positive control: the swapped-in condition exists");
|
|
}
|
|
finally { await host.DisposeAsync(); }
|
|
}
|
|
|
|
/// <summary>Registers a stub item on <paramref name="rawPath"/> and stands it down through a denying pass.</summary>
|
|
private static async Task<(StubItem Item, NodeId NodeId)> StandDownAsync(OtOpcUaNodeManager nm, string rawPath)
|
|
{
|
|
nm.EnsureVariable(rawPath, null, rawPath, "Float", writable: false, realm: AddressSpaceRealm.Raw);
|
|
var nodeId = nm.TryGetVariable(rawPath, AddressSpaceRealm.Raw)!.NodeId;
|
|
var handle = (NodeHandle)nm.GetManagerHandle(nodeId)!;
|
|
var item = new StubItem(handle, Identity());
|
|
nm.TrackLiveMonitoredItem(handle, item);
|
|
|
|
nm.Authorizer = Authorizer(permits: false);
|
|
nm.RevalidateMonitoredItems();
|
|
item.Statuses.ShouldContain((StatusCode)StatusCodes.BadUserAccessDenied, "non-vacuity: the item really was stood down");
|
|
await Task.CompletedTask;
|
|
return (item, nodeId);
|
|
}
|
|
|
|
private static DataPlaneAuthorizer Authorizer(bool permits)
|
|
{
|
|
var state = new DataPlaneAuthorityState();
|
|
var grants = permits
|
|
? new[]
|
|
{
|
|
new NodeAcl
|
|
{
|
|
NodeAclId = "acl-cluster", ClusterId = Cluster, Principal = Principal,
|
|
ScopeKind = NodeAclScopeKind.Cluster, PermissionFlags = NodePermissions.Subscribe,
|
|
},
|
|
}
|
|
: [];
|
|
state.Apply(Cluster, "rev-1", [], grants, new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>());
|
|
return new DataPlaneAuthorizer(state, new TriePermissionEvaluator(state.Tries), enabled: true, NullLogger.Instance);
|
|
}
|
|
|
|
private static RoleCarryingUserIdentity Identity() =>
|
|
new(new UserNameIdentityToken { UserName = "apikey" }, [], Principal, apiKeyId: null);
|
|
|
|
private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync() =>
|
|
await OpcUaHostStart.StartAsync(
|
|
port => new OpcUaApplicationHost(
|
|
new OpcUaApplicationHostOptions
|
|
{
|
|
ApplicationName = "OtOpcUa.StaleTest",
|
|
ApplicationUri = $"urn:OtOpcUa.StaleTest:{Guid.NewGuid():N}",
|
|
OpcUaPort = port,
|
|
PublicHostname = "localhost",
|
|
PkiStoreRoot = _pkiRoot,
|
|
},
|
|
NullLogger<OpcUaApplicationHost>.Instance),
|
|
() => new OtOpcUaSdkServer(), Ct);
|
|
|
|
public void Dispose()
|
|
{
|
|
if (Directory.Exists(_pkiRoot)) Directory.Delete(_pkiRoot, recursive: true);
|
|
}
|
|
|
|
/// <summary>The minimum of a sampled item the removal path touches: its handle, identity and value queue.</summary>
|
|
private sealed class StubItem(NodeHandle handle, IUserIdentity identity) : ISampledDataChangeMonitoredItem
|
|
{
|
|
public List<StatusCode> Statuses { get; } = [];
|
|
public IUserIdentity EffectiveIdentity => identity;
|
|
public object ManagerHandle => handle;
|
|
public NodeId NodeId => handle.NodeId;
|
|
public void QueueValue(in DataValue value, ServiceResult? error, bool ignoreFilters) => Statuses.Add(error?.StatusCode ?? value.StatusCode);
|
|
public void QueueValue(in DataValue value, ServiceResult? error) => QueueValue(value, error, false);
|
|
public void Dispose() { }
|
|
|
|
public IAsyncNodeManager NodeManager => throw new NotSupportedException();
|
|
public ISession Session => throw new NotSupportedException();
|
|
public uint Id => throw new NotSupportedException();
|
|
public uint SubscriptionId => throw new NotSupportedException();
|
|
public bool IsDurable => throw new NotSupportedException();
|
|
public uint ClientHandle => throw new NotSupportedException();
|
|
public ISubscription SubscriptionCallback { get => throw new NotSupportedException(); set => throw new NotSupportedException(); }
|
|
public int MonitoredItemType => throw new NotSupportedException();
|
|
public bool IsReadyToPublish => throw new NotSupportedException();
|
|
public bool IsReadyToTrigger { get => throw new NotSupportedException(); set => throw new NotSupportedException(); }
|
|
public bool IsResendData => throw new NotSupportedException();
|
|
public MonitoringMode MonitoringMode => throw new NotSupportedException();
|
|
public double SamplingInterval => throw new NotSupportedException();
|
|
public DataChangeFilter DataChangeFilter => throw new NotSupportedException();
|
|
public uint AttributeId => throw new NotSupportedException();
|
|
public NumericRange IndexRange => throw new NotSupportedException();
|
|
public QualifiedName DataEncoding => throw new NotSupportedException();
|
|
public bool AlwaysReportUpdates { get => throw new NotSupportedException(); set => throw new NotSupportedException(); }
|
|
public DiagnosticsMasks DiagnosticsMasks => throw new NotSupportedException();
|
|
public uint QueueSize => throw new NotSupportedException();
|
|
public double MinimumSamplingInterval => throw new NotSupportedException();
|
|
public int TimeToNextSample => throw new NotSupportedException();
|
|
public void SetupResendDataTrigger() => throw new NotSupportedException();
|
|
public ServiceResult GetCreateResult(out MonitoredItemCreateResult result) => throw new NotSupportedException();
|
|
public ServiceResult GetModifyResult(out MonitoredItemModifyResult result) => throw new NotSupportedException();
|
|
public IStoredMonitoredItem ToStorableMonitoredItem() => throw new NotSupportedException();
|
|
public MonitoringMode SetMonitoringMode(MonitoringMode monitoringMode) => throw new NotSupportedException();
|
|
public bool Publish(OperationContext context, Queue<MonitoredItemNotification> notifications, Queue<DiagnosticInfo> diagnostics, uint maxNotificationsPerPublish, ILogger logger) => throw new NotSupportedException();
|
|
public void SetSemanticsChanged() => throw new NotSupportedException();
|
|
public void SetStructureChanged() => throw new NotSupportedException();
|
|
public bool SamplingIntervalExpired() => throw new NotSupportedException();
|
|
public ReadValueId GetReadValueId() => throw new NotSupportedException();
|
|
public ServiceResult ModifyAttributes(DiagnosticsMasks diagnosticsMasks, TimestampsToReturn timestampsToReturn, uint clientHandle, MonitoringFilter originalFilter, MonitoringFilter filterToUse, Opc.Ua.Range range, double samplingInterval, uint queueSize, bool discardOldest) => throw new NotSupportedException();
|
|
public void SetSamplingInterval(double samplingInterval) => throw new NotSupportedException();
|
|
}
|
|
}
|