diff --git a/plans/ctt-issues.md b/plans/ctt-issues.md index 7cdf4b4168..5975254819 100644 --- a/plans/ctt-issues.md +++ b/plans/ctt-issues.md @@ -8,7 +8,7 @@ CTT project configuration notes follow the tables. The procedure for running the - "Resolved / fixed" in Mantis means the fix is in the CTT script repository. It ships with a script build after 1.05.513, so an installed 1.05.513 still shows the failure. -- The ids (1–19, C1–C56, U1–U5) are stable references for notes and commit messages; missing ids were +- The ids (1–19, C1–C57, U1–U5) are stable references for notes and commit messages; missing ids were withdrawn or no longer fail against the reference server. - Mantis states were last checked on 2026-09-25. @@ -98,6 +98,7 @@ CTT project configuration notes follow the tables. The procedure for running the | C30 | Query Applications `036.js` | Expects `rcp+` DiscoveryUrls the test never registered. | [11412](https://mantis.opcfoundation.org/view.php?id=11412) | | C31 | Query Applications `038.js` | Treats applicationType 3 as invalid. | [11413](https://mantis.opcfoundation.org/view.php?id=11413) | | C51 | Application Directory `066.js`, `079.js`; Query Applications `039.js` (pass only because the server returns NA records) | Register the reference Servers with ServerCapabilities `NA` and expect them in query results, which OPC 10000-12 §6.5.10/§6.5.11 excludes; with the exclusion 40 test cases fail. | [11458](https://mantis.opcfoundation.org/view.php?id=11458) | +| C57 | Application Directory `011.js`, `018.js`, `028.js` | Expect the ApplicationRegistrationChangedAuditEvents on the Session of the DiscoveryAdmin user, but the GDS NodeSet grants ReceiveEvents on that EventType only to SecurityAdmin (OPC 10000-3 §8.55); the subscription belongs on a second Session of the `SecurityAdminUser`. | [11482](https://mantis.opcfoundation.org/view.php?id=11482) | ### Alarms and Conditions diff --git a/tests/Opc.Ua.Gds.Tests/GdsApplicationRegistrationAuditEventTests.cs b/tests/Opc.Ua.Gds.Tests/GdsApplicationRegistrationAuditEventTests.cs new file mode 100644 index 0000000000..2e6670c4cf --- /dev/null +++ b/tests/Opc.Ua.Gds.Tests/GdsApplicationRegistrationAuditEventTests.cs @@ -0,0 +1,301 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using Opc.Ua.Client; + +namespace Opc.Ua.Gds.Tests +{ + /// + /// The ApplicationRegistrationChangedAuditEvents of RegisterApplication, + /// UpdateApplication and UnregisterApplication (OPC 10000-12 6.5.12) + /// reach a subscriber on Server.EventNotifier. + /// + /// + /// A Client only receives an Event if it has ReceiveEvents on the + /// EventType Node (OPC 10000-3 8.55), and the GDS NodeSet grants it on + /// ApplicationRegistrationChangedAuditEventType (AuditEventDefaultPermissions) + /// only to SecurityAdmin. A DiscoveryAdmin may call the Methods but + /// does not receive their audit events; the CTT GDS Application + /// Directory test cases 011, 018 and 028 subscribe as the DiscoveryAdmin + /// user and therefore have to subscribe with a SecurityAdmin Session. + /// + [TestFixture] + [Category("GDS")] + [Category("GDSApplicationDirectory")] + [Category("Audit")] + public sealed class GdsApplicationRegistrationAuditEventTests : GdsTestFixture + { + [OneTimeSetUp] + public async Task AuditEventSetUpAsync() + { + m_directoryNodeId = ToNodeId(ObjectIds.Directory); + m_eventTypeId = ToNodeId(ObjectTypeIds.ApplicationRegistrationChangedAuditEventType); + + m_discoveryAdminSession = await ClientFixture + .ConnectAsync( + ServerUrl, + SecurityPolicies.Basic256Sha256, + default, + new UserIdentity("DiscoveryAdmin", "demo"u8)) + .ConfigureAwait(false); + if (!m_discoveryAdminSession.MessageContext.Factory.ContainsEncodeableType( + DataTypeIds.ApplicationRecordDataType)) + { + m_discoveryAdminSession.MessageContext.Factory.Builder.AddOpcUaGds().Commit(); + } + } + + [OneTimeTearDown] + public async Task AuditEventTearDownAsync() + { + if (m_discoveryAdminSession != null) + { + await m_discoveryAdminSession.CloseAsync(5000, true).ConfigureAwait(false); + m_discoveryAdminSession.Dispose(); + m_discoveryAdminSession = null; + } + } + + [Test] + public async Task ApplicationRegistrationAuditEventsReachSecurityAdminSubscriberAsync() + { + // The fixture Session is a SecurityAdmin; the Methods are called by + // a DiscoveryAdmin, like the CTT does. + uint securityAdminSubscription = await SubscribeToServerEventsAsync(Session) + .ConfigureAwait(false); + uint discoveryAdminSubscription = await SubscribeToServerEventsAsync(m_discoveryAdminSession) + .ConfigureAwait(false); + try + { + ApplicationRecordDataType record = CreateTestApplicationRecord("AuditEvents"); + NodeId applicationId = await RegisterApplicationAsync(record).ConfigureAwait(false); + record.ApplicationId = applicationId; + record.ProductUri = "urn:opcfoundation.org:tests:test:product:AuditEventsUpdated"; + await CallDirectoryMethodAsync( + MethodIds.Directory_UpdateApplication, + new Variant(new ExtensionObject(record))).ConfigureAwait(false); + await CallDirectoryMethodAsync( + MethodIds.Directory_UnregisterApplication, + new Variant(applicationId)).ConfigureAwait(false); + + NodeId[] expectedMethods = + [ + ToNodeId(MethodIds.Directory_RegisterApplication), + ToNodeId(MethodIds.Directory_UpdateApplication), + ToNodeId(MethodIds.Directory_UnregisterApplication) + ]; + + List received = await CollectRegistrationEventsAsync( + Session, + events => expectedMethods.All(m => events.Any(e => e.MethodId == m))) + .ConfigureAwait(false); + + foreach (NodeId methodId in expectedMethods) + { + RegistrationAuditEvent auditEvent = received.FirstOrDefault(e => e.MethodId == methodId); + Assert.That(auditEvent, Is.Not.Null, + $"No ApplicationRegistrationChangedAuditEvent for Method {methodId}; received " + + $"{received.Count} event(s) of that type."); + Assert.That(auditEvent.SourceNode, Is.EqualTo(m_directoryNodeId), + "The SourceNode of the audit event shall be the Directory Object."); + } + + // The DiscoveryAdmin has no ReceiveEvents on the EventType. + List discoveryAdminEvents = await CollectRegistrationEventsAsync( + m_discoveryAdminSession, + _ => false, + TimeSpan.FromSeconds(1)).ConfigureAwait(false); + Assert.That(discoveryAdminEvents, Is.Empty, + "Only SecurityAdmin has ReceiveEvents on ApplicationRegistrationChangedAuditEventType."); + } + finally + { + await DeleteSubscriptionAsync(Session, securityAdminSubscription).ConfigureAwait(false); + await DeleteSubscriptionAsync(m_discoveryAdminSession, discoveryAdminSubscription) + .ConfigureAwait(false); + } + } + + private static async Task SubscribeToServerEventsAsync(ISession session) + { + CreateSubscriptionResponse subscription = await session.CreateSubscriptionAsync( + null, 100, 1000, 10, 0, true, 0, CancellationToken.None).ConfigureAwait(false); + + var filter = new EventFilter + { + SelectClauses = + [ + Field(Ua.ObjectTypeIds.BaseEventType, Ua.BrowseNames.EventType), + Field(Ua.ObjectTypeIds.BaseEventType, Ua.BrowseNames.SourceNode), + Field(Ua.ObjectTypeIds.AuditUpdateMethodEventType, Ua.BrowseNames.MethodId) + ], + WhereClause = new ContentFilter() + }; + CreateMonitoredItemsResponse items = await session.CreateMonitoredItemsAsync( + null, + subscription.SubscriptionId, + TimestampsToReturn.Neither, + new MonitoredItemCreateRequest[] + { + new() + { + ItemToMonitor = new ReadValueId + { + NodeId = Ua.ObjectIds.Server, + AttributeId = Attributes.EventNotifier + }, + MonitoringMode = MonitoringMode.Reporting, + RequestedParameters = new MonitoringParameters + { + ClientHandle = 1, + SamplingInterval = 0, + Filter = new ExtensionObject(filter), + QueueSize = 100, + DiscardOldest = true + } + } + }.ToArrayOf(), + CancellationToken.None).ConfigureAwait(false); + + Assert.That(items.Results.Count, Is.EqualTo(1)); + Assert.That(StatusCode.IsGood(items.Results[0].StatusCode), Is.True, + $"Subscribing to the events of the Server failed: {items.Results[0].StatusCode}"); + return subscription.SubscriptionId; + } + + private static SimpleAttributeOperand Field(NodeId typeDefinitionId, string browseName) + { + return new SimpleAttributeOperand + { + TypeDefinitionId = typeDefinitionId, + BrowsePath = [new QualifiedName(browseName)], + AttributeId = Attributes.Value + }; + } + + /// + /// Publishes until returns true or the + /// timeout expires and returns the ApplicationRegistrationChangedAuditEvents. + /// + private async Task> CollectRegistrationEventsAsync( + ISession session, + Func, bool> isComplete, + TimeSpan? timeout = null) + { + var received = new List(); + DateTime deadline = DateTime.UtcNow + (timeout ?? TimeSpan.FromSeconds(10)); + while (!isComplete(received) && DateTime.UtcNow < deadline) + { + PublishResponse response = await session.PublishAsync( + null, + Array.Empty().ToArrayOf(), + CancellationToken.None).ConfigureAwait(false); + + foreach (ExtensionObject notification in response.NotificationMessage.NotificationData) + { + if (!notification.TryGetValue(out EventNotificationList eventList)) + { + continue; + } + foreach (EventFieldList fields in eventList.Events) + { + if (fields.EventFields.Count == 3 && + fields.EventFields[0].TryGetValue(out NodeId eventType) && + eventType == m_eventTypeId) + { + fields.EventFields[1].TryGetValue(out NodeId sourceNode); + fields.EventFields[2].TryGetValue(out NodeId methodId); + received.Add(new RegistrationAuditEvent(sourceNode, methodId)); + } + } + } + } + return received; + } + + private static async Task DeleteSubscriptionAsync(ISession session, uint subscriptionId) + { + try + { + await session.DeleteSubscriptionsAsync( + null, + new uint[] { subscriptionId }.ToArrayOf(), + CancellationToken.None).ConfigureAwait(false); + } + catch (ServiceResultException) + { + // best effort + } + } + + private async Task RegisterApplicationAsync(ApplicationRecordDataType record) + { + CallMethodResult result = await CallDirectoryMethodAsync( + MethodIds.Directory_RegisterApplication, + new Variant(new ExtensionObject(record))).ConfigureAwait(false); + Assert.That(result.OutputArguments.Count, Is.EqualTo(1)); + return (NodeId)result.OutputArguments[0]; + } + + private async Task CallDirectoryMethodAsync( + ExpandedNodeId methodId, + Variant argument) + { + CallResponse response = await m_discoveryAdminSession.CallAsync( + null, + new CallMethodRequest[] + { + new() + { + ObjectId = m_directoryNodeId, + MethodId = ToNodeId(methodId), + InputArguments = new Variant[] { argument }.ToArrayOf() + } + }.ToArrayOf(), + CancellationToken.None).ConfigureAwait(false); + + Assert.That(response.Results.Count, Is.EqualTo(1)); + Assert.That(StatusCode.IsGood(response.Results[0].StatusCode), Is.True, + $"Calling {methodId} as DiscoveryAdmin failed: {response.Results[0].StatusCode}"); + return response.Results[0]; + } + + private sealed record RegistrationAuditEvent(NodeId SourceNode, NodeId MethodId); + + private NodeId m_directoryNodeId; + private NodeId m_eventTypeId; + private ISession m_discoveryAdminSession; + } +}