127 lines
7.4 KiB
C#
127 lines
7.4 KiB
C#
using Microsoft.Extensions.Logging.Abstractions;
|
|
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.Authorization;
|
|
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
|
using static ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.ServerEventRig;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
|
|
|
|
/// <summary>The SDK2 already-acknowledged/confirmed compatibility path must still consult the current application authority.</summary>
|
|
public sealed class Sdk2RepeatedAlarmAuthorityWireTests
|
|
{
|
|
public static TheoryData<bool, string, string> Cases => new(
|
|
from operation in new[] { (Native: true, Method: "Acknowledge"), (Native: false, Method: "Acknowledge"), (Native: false, Method: "Confirm") }
|
|
from change in new[] { "grant", "key", "expiry" }
|
|
select (operation.Native, operation.Method, change));
|
|
|
|
[Theory]
|
|
[MemberData(nameof(Cases))]
|
|
public async Task A_current_trunk_retry_uses_its_application_grant_and_current_key(bool native, string method, string change)
|
|
{
|
|
var ct = TestContext.Current.CancellationToken;
|
|
await using var rig = await StartAsync("RepeatAlarmAuthority", encrypted: true, ct);
|
|
rig.KeyRoles = [];
|
|
var clock = new Clock();
|
|
var key = Key(); key.ExpiresUtc = clock.GetUtcNow().UtcDateTime.AddMinutes(10);
|
|
var permissions = NodePermissions.ReadOnly | NodePermissions.AlarmAcknowledge | NodePermissions.AlarmConfirm | NodePermissions.MethodCall;
|
|
var grant = new NodeAcl
|
|
{
|
|
NodeAclId = native ? "narrow-native-alarm" : "cluster-scripted-alarm", ClusterId = ServerEventRig.Cluster, Principal = KeyPrincipal,
|
|
ScopeKind = native ? NodeAclScopeKind.RawDevice : NodeAclScopeKind.Cluster,
|
|
ScopeId = native ? "alarm-owner" : null, PermissionFlags = permissions,
|
|
};
|
|
IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> scopes =
|
|
new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>
|
|
{
|
|
[new(grant.ScopeKind, "alarm-owner")] = native
|
|
? [new([NodeAclRealms.Raw, "Plant", "Modbus", "dev1"])]
|
|
: [],
|
|
};
|
|
var state = rig.Enforce(true, [key], scopes, grant);
|
|
rig.Nm.Authorizer = new DataPlaneAuthorizer(state, new TriePermissionEvaluator(state.Tries), true, NullLogger.Instance, clock);
|
|
if (native) rig.MaterialiseNative(); else rig.MaterialiseScripted();
|
|
var realm = native ? AddressSpaceRealm.Raw : AddressSpaceRealm.Uns;
|
|
var path = native ? RawAlarmPath : ScriptedAlarmId;
|
|
var routed = 0;
|
|
rig.Nm.NativeAlarmAckRouter = _ => Interlocked.Increment(ref routed);
|
|
rig.Nm.AlarmCommandRouter = _ => Interlocked.Increment(ref routed);
|
|
|
|
using var session = await rig.ConnectAsync(anonymous: false, ct, encrypted: true);
|
|
var identity = rig.Server.CurrentInstance.SessionManager.GetSessions().ShouldHaveSingleItem().EffectiveIdentity
|
|
.ShouldBeOfType<RoleCarryingUserIdentity>();
|
|
identity.Roles.ShouldBeEmpty("the positive retry must be authorized by the application grant alone");
|
|
// Native: narrow raw-device scope. Scripted: preserve the existing cluster-scope contract;
|
|
// opaque scripted IDs are not under their equipment ACL path (a separately tracked baseline defect).
|
|
identity.ApiKeyId.ShouldBe(KeyId);
|
|
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();
|
|
rig.Project(native, new AlarmConditionSnapshot(true, true, true, true, AlarmShelvingKind.Unshelved, 500, "retry"));
|
|
await RecordedEvents.WaitUntilAsync(() => events.ConditionEvents(item, "retry").Count == 1);
|
|
var condition = rig.Nm.TryGetAlarmCondition(path, realm).ShouldNotBeNull();
|
|
var methodId = method == "Acknowledge" ? MethodIds.AcknowledgeableConditionType_Acknowledge : MethodIds.AcknowledgeableConditionType_Confirm;
|
|
var eventIdNode = condition.EventId.ShouldNotBeNull();
|
|
var commentNode = condition.Comment.ShouldNotBeNull();
|
|
var ackNode = condition.AckedState.ShouldNotBeNull().Id.ShouldNotBeNull();
|
|
var confirmNode = condition.ConfirmedState.ShouldNotBeNull().Id.ShouldNotBeNull();
|
|
var initialId = eventIdNode.Value;
|
|
await session.CallAsync(condition.NodeId, methodId, ct,
|
|
Variant.From(initialId), Variant.From(new LocalizedText("allowed retry")));
|
|
// A commented re-ack reports its comment. An already-confirmed retry has no state delta
|
|
// and remains silent under the existing Confirm reporting contract.
|
|
var expectedEvents = method == "Confirm" ? 1 : 2;
|
|
if (expectedEvents == 2)
|
|
await RecordedEvents.WaitUntilAsync(() => events.ConditionEvents(item, "retry").Count == expectedEvents);
|
|
routed.ShouldBe(1, "each authorized call reaches its owner once; the scripted owner decides whether a retry changes state");
|
|
var currentId = eventIdNode.Value;
|
|
currentId.IsEmpty.ShouldBeFalse();
|
|
var beforeEvents = events.Of(item).Count;
|
|
var beforeRoutes = Volatile.Read(ref routed);
|
|
var beforeComment = commentNode.Value;
|
|
var beforeAck = ackNode.Value;
|
|
var beforeConfirm = confirmNode.Value;
|
|
|
|
var keys = new[] { key };
|
|
var grants = new[] { grant };
|
|
if (change == "grant") grants = [];
|
|
if (change == "key") { var disabled = Key(); disabled.Enabled = false; keys = [disabled]; }
|
|
if (change == "expiry") clock.Advance(TimeSpan.FromMinutes(20));
|
|
else state.Apply(ServerEventRig.Cluster, "revoked", keys, grants, scopes);
|
|
|
|
var error = await Should.ThrowAsync<ServiceResultException>(() => session.CallAsync(condition.NodeId, methodId, ct,
|
|
Variant.From(currentId), Variant.From(new LocalizedText("must not be recorded"))));
|
|
error.StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied);
|
|
Volatile.Read(ref routed).ShouldBe(beforeRoutes);
|
|
eventIdNode.Value.ShouldBe(currentId);
|
|
commentNode.Value.ShouldBe(beforeComment);
|
|
ackNode.Value.ShouldBe(beforeAck);
|
|
confirmNode.Value.ShouldBe(beforeConfirm);
|
|
// A subsequent authorized same-item sentinel drains any queued method event before the assertion.
|
|
clock.Reset();
|
|
state.Apply(ServerEventRig.Cluster, "restored", [key], [grant], scopes);
|
|
rig.Project(native, new AlarmConditionSnapshot(true, true, true, true, AlarmShelvingKind.Unshelved, 500, "sentinel"));
|
|
await RecordedEvents.WaitUntilAsync(() => events.Has(item, "sentinel"));
|
|
events.Of(item).Count.ShouldBe(beforeEvents + 1, "the denied retry emits nothing before the sentinel");
|
|
events.ConditionEvents(item, "retry").Count.ShouldBe(expectedEvents);
|
|
await subscription.DeleteAsync(true, ct);
|
|
await session.CloseAsync(ct);
|
|
}
|
|
|
|
private sealed class Clock : TimeProvider
|
|
{
|
|
private readonly DateTimeOffset _initial = DateTimeOffset.UtcNow;
|
|
private TimeSpan _offset;
|
|
public override DateTimeOffset GetUtcNow() => _initial + _offset;
|
|
public void Advance(TimeSpan time) => _offset += time;
|
|
public void Reset() => _offset = TimeSpan.Zero;
|
|
}
|
|
}
|