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;
/// The SDK2 already-acknowledged/confirmed compatibility path must still consult the current application authority.
public sealed class Sdk2RepeatedAlarmAuthorityWireTests
{
public static TheoryData 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> scopes =
new Dictionary>
{
[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();
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(() => 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;
}
}