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.OpcUaServer.Security; namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; public sealed class Sdk2ConditionMethodCompatibilityTests : IAsyncLifetime { private readonly string _pki = Path.Combine(Path.GetTempPath(), $"otua-sdk2-methods-{Guid.NewGuid():N}"); private OpcUaApplicationHost _host = null!; private OtOpcUaSdkServer _server = null!; private OtOpcUaNodeManager _manager = null!; private readonly List _scripted = []; private readonly List _native = []; public async ValueTask InitializeAsync() { var started = await OpcUaHostStart.StartAsync(port => new OpcUaApplicationHost(new OpcUaApplicationHostOptions { ApplicationName = "SDK2.MethodContracts", ApplicationUri = "urn:SDK2.MethodContracts", OpcUaPort = port, PublicHostname = "localhost", PkiStoreRoot = _pki, }, NullLogger.Instance), () => new OtOpcUaSdkServer(), TestContext.Current.CancellationToken); _host = started.Host; _server = started.Server; _manager = _server.NodeManager!; _manager.AlarmCommandRouter = _scripted.Add; _manager.NativeAlarmAckRouter = _native.Add; } public async ValueTask DisposeAsync() { await _host.DisposeAsync(); Directory.Delete(_pki, true); } [Theory] [InlineData(false, null)] [InlineData(false, "again")] [InlineData(true, null)] [InlineData(true, "again")] public void Actual_SDK_method_preserves_current_trunk_reack_and_comment(bool native, string? comment) { var condition = Condition(native); Call(condition, condition.Acknowledge!, condition.EventId!.Value, "first").ShouldBe(ServiceResult.Good); if (!native) Call(condition, condition.Confirm!, condition.EventId.Value, "confirmed").ShouldBe(ServiceResult.Good); var before = condition.EventId.Value; Call(condition, condition.Acknowledge!, before, comment).ShouldBe(ServiceResult.Good); condition.AckedState!.Id!.Value.ShouldBeTrue(); condition.ConfirmedState!.Id!.Value.ShouldBeTrue(); if (comment is null) condition.EventId.Value.ShouldBe(before); else { condition.EventId.Value.ShouldNotBe(before); condition.Comment!.Value.Text.ShouldBe(comment); } if (native) _native.Count.ShouldBe(2, "a native re-ack is an upstream retry"); else _scripted.Count(x => x.Operation == "Acknowledge").ShouldBe(2); } [Fact] public void Actual_SDK_native_confirm_keeps_the_application_unsupported_status() { var condition = Condition(native: true); var before = condition.EventId!.Value; Call(condition, condition.Confirm!, before, null).StatusCode.ShouldBe(StatusCodes.BadNotSupported); condition.ConfirmedState!.Id!.Value.ShouldBeTrue(); condition.EventId.Value.ShouldBe(before); _native.ShouldBeEmpty(); _scripted.ShouldBeEmpty(); } [Fact] public void Current_trunk_reack_still_requires_app_authority_primary_and_native_preflight() { var condition = Condition(native: true); Call(condition, condition.Acknowledge!, condition.EventId!.Value, null).ShouldBe(ServiceResult.Good); var before = condition.EventId.Value; Call(condition, condition.Acknowledge!, before, null, authorized: false).StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied); _manager.NativeAlarmAckPrimaryGate = () => false; Call(condition, condition.Acknowledge!, before, null).StatusCode.ShouldBe(StatusCodes.BadInvalidState); _manager.NativeAlarmAckPrimaryGate = () => true; _manager.NativeAlarmAckPreflight = _ => "driver unavailable"; Call(condition, condition.Acknowledge!, before, null).StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied); condition.EventId.Value.ShouldBe(before); _native.Count.ShouldBe(1); } [Theory] [InlineData("empty")] [InlineData("foreign")] [InlineData("superseded")] [InlineData("disabled")] public void Actual_SDK_method_keeps_event_and_enabled_guards(string invalid) { var condition = Condition(native: false); var original = condition.EventId!.Value; Call(condition, condition.Acknowledge!, original, null).ShouldBe(ServiceResult.Good); var current = condition.EventId.Value; var token = invalid switch { "empty" => default, "foreign" => ByteString.From(Guid.NewGuid().ToByteArray()), "superseded" => original, _ => current, }; if (invalid == "disabled") condition.SetEnableState(Context(), false); var routes = _scripted.Count; ServiceResult.IsBad(Call(condition, condition.Acknowledge!, token, null)).ShouldBeTrue(); _scripted.Count.ShouldBe(routes); condition.EventId.Value.ShouldBe(current); } [Theory] [InlineData("OffNormalAlarm")] [InlineData("DiscreteAlarm")] [InlineData("AlarmCondition")] public void An_acknowledged_branch_is_constructible_and_does_not_get_the_trunk_repeat_waiver(string type) { var condition = Condition(native: false, type); var branch = condition.CreateBranch(Context(), new NodeId(Guid.NewGuid())).ShouldBeAssignableTo()!; branch.EventId!.Value = ByteString.From(Guid.NewGuid().ToByteArray()); branch.SetAcknowledgedState(Context(), true); var routes = _scripted.Count; Call(condition, condition.Acknowledge!, branch.EventId!.Value, null).StatusCode.ShouldBe(StatusCodes.BadConditionBranchAlreadyAcked); _scripted.Count.ShouldBe(routes); } private AlarmConditionState Condition(bool native, string type = "OffNormalAlarm") { var realm = native ? AddressSpaceRealm.Raw : AddressSpaceRealm.Uns; _manager.EnsureFolder("eq", null, "Equipment", realm); _manager.MaterialiseAlarmCondition("alarm", "eq", "Alarm", type, 700, realm, isNative: native); _manager.WriteAlarmCondition("alarm", new AlarmConditionSnapshot(true, false, true, true, AlarmShelvingKind.Unshelved, 700, "active"), DateTime.UtcNow, realm); return _manager.TryGetAlarmCondition("alarm", realm)!; } private ServerSystemContext Context(bool authorized = true) => new(_server.CurrentInstance) { UserIdentity = new RoleCarryingUserIdentity(new AnonymousIdentityToken(), authorized ? [OpcUaDataPlaneRoles.AlarmAck] : [], "operator", null), }; private ServiceResult Call(AlarmConditionState condition, AddCommentMethodState method, ByteString eventId, string? comment, bool authorized = true) { var argumentErrors = new List(); var result = method.Call(Context(authorized), condition.NodeId, [Variant.From(eventId), Variant.From(comment is null ? default(LocalizedText) : new LocalizedText(comment))], argumentErrors, new List()); argumentErrors.All(ServiceResult.IsGood).ShouldBeTrue(); return result; } }