using Microsoft.Extensions.Logging.Abstractions; using Opc.Ua; using Opc.Ua.Client; using Opc.Ua.Configuration; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Configuration.Entities; using ZB.MOM.WW.OtOpcUa.Configuration.Enums; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.Hosting; using ZB.MOM.WW.OtOpcUa.Core.OpcUa; using ZB.MOM.WW.OtOpcUa.Server.OpcUa; using ZB.MOM.WW.OtOpcUa.Server.Security; namespace ZB.MOM.WW.OtOpcUa.Server.Tests; /// /// End-to-end proof that ADR-001 Option A wire-in (#212) flows: when /// is given an equipmentContentLookup that /// returns a non-null , the walker runs BEFORE /// the driver's DiscoverAsync + the UNS folder skeleton (Area → Line → Equipment) + /// identifier properties are materialized into the driver's namespace + visible to an /// OPC UA client via standard browse. /// [Trait("Category", "Integration")] public sealed class OpcUaEquipmentWalkerIntegrationTests : IAsyncLifetime { private static readonly int Port = 48500 + Random.Shared.Next(0, 99); private readonly string _endpoint = $"opc.tcp://localhost:{Port}/OtOpcUaWalkerTest"; private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-walker-{Guid.NewGuid():N}"); private const string DriverId = "galaxy-prod"; private DriverHost _driverHost = null!; private OpcUaApplicationHost _server = null!; public async ValueTask InitializeAsync() { _driverHost = new DriverHost(); await _driverHost.RegisterAsync(new EmptyDriver(DriverId), "{}", CancellationToken.None); var content = BuildFixture(); var options = new OpcUaServerOptions { EndpointUrl = _endpoint, ApplicationName = "OtOpcUaWalkerTest", ApplicationUri = "urn:OtOpcUa:Server:WalkerTest", PkiStoreRoot = _pkiRoot, AutoAcceptUntrustedClientCertificates = true, HealthEndpointsEnabled = false, }; _server = new OpcUaApplicationHost( options, _driverHost, new DenyAllUserAuthenticator(), NullLoggerFactory.Instance, NullLogger.Instance, equipmentContentLookup: id => id == DriverId ? content : null); await _server.StartAsync(CancellationToken.None); } public async ValueTask DisposeAsync() { await _server.DisposeAsync(); await _driverHost.DisposeAsync(); try { Directory.Delete(_pkiRoot, recursive: true); } catch { /* best-effort */ } } [Fact] public async Task Walker_Materializes_Area_Line_Equipment_Folders_Visible_Via_Browse() { using var session = await OpenSessionAsync(); var nsIndex = (ushort)session.NamespaceUris.GetIndex($"urn:OtOpcUa:{DriverId}"); var areaFolder = new NodeId($"{DriverId}/warsaw", nsIndex); var lineFolder = new NodeId($"{DriverId}/warsaw/line-a", nsIndex); var equipmentFolder = new NodeId($"{DriverId}/warsaw/line-a/oven-3", nsIndex); BrowseChildren(session, areaFolder).ShouldContain(r => r.BrowseName.Name == "line-a"); BrowseChildren(session, lineFolder).ShouldContain(r => r.BrowseName.Name == "oven-3"); var equipmentChildren = BrowseChildren(session, equipmentFolder); equipmentChildren.ShouldContain(r => r.BrowseName.Name == "EquipmentId"); equipmentChildren.ShouldContain(r => r.BrowseName.Name == "EquipmentUuid"); equipmentChildren.ShouldContain(r => r.BrowseName.Name == "MachineCode"); } [Fact] public async Task Walker_Emits_Tag_Variable_Under_Equipment_Readable_By_Client() { using var session = await OpenSessionAsync(); var nsIndex = (ushort)session.NamespaceUris.GetIndex($"urn:OtOpcUa:{DriverId}"); var tagNode = new NodeId("plcaddr-temperature", nsIndex); var equipmentFolder = new NodeId($"{DriverId}/warsaw/line-a/oven-3", nsIndex); BrowseChildren(session, equipmentFolder).ShouldContain(r => r.BrowseName.Name == "Temperature"); var dv = session.ReadValue(tagNode); dv.ShouldNotBeNull(); } private static ReferenceDescriptionCollection BrowseChildren(ISession session, NodeId node) { session.Browse(null, null, node, 0, BrowseDirection.Forward, ReferenceTypeIds.HierarchicalReferences, true, (uint)NodeClass.Object | (uint)NodeClass.Variable, out _, out var refs); return refs; } private static EquipmentNamespaceContent BuildFixture() { var area = new UnsArea { UnsAreaId = "area-1", ClusterId = "c-local", Name = "warsaw", GenerationId = 1 }; var line = new UnsLine { UnsLineId = "line-a", UnsAreaId = "area-1", Name = "line-a", GenerationId = 1 }; var oven = new Equipment { EquipmentRowId = Guid.NewGuid(), GenerationId = 1, EquipmentId = "eq-oven-3", EquipmentUuid = Guid.NewGuid(), DriverInstanceId = DriverId, UnsLineId = "line-a", Name = "oven-3", MachineCode = "MC-oven-3", }; var tempTag = new Tag { TagRowId = Guid.NewGuid(), GenerationId = 1, TagId = "tag-1", DriverInstanceId = DriverId, EquipmentId = "eq-oven-3", Name = "Temperature", DataType = "Int32", AccessLevel = TagAccessLevel.ReadWrite, TagConfig = "plcaddr-temperature", }; return new EquipmentNamespaceContent( Areas: new[] { area }, Lines: new[] { line }, Equipment: new[] { oven }, Tags: new[] { tempTag }); } private async Task OpenSessionAsync() { var cfg = new ApplicationConfiguration { ApplicationName = "OtOpcUaWalkerTestClient", ApplicationUri = "urn:OtOpcUa:WalkerTestClient", ApplicationType = ApplicationType.Client, SecurityConfiguration = new SecurityConfiguration { ApplicationCertificate = new CertificateIdentifier { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-own"), SubjectName = "CN=OtOpcUaWalkerTestClient", }, TrustedIssuerCertificates = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-issuers") }, TrustedPeerCertificates = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-trusted") }, RejectedCertificateStore = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-rejected") }, AutoAcceptUntrustedCertificates = true, AddAppCertToTrustedStore = true, }, TransportConfigurations = new TransportConfigurationCollection(), TransportQuotas = new TransportQuotas { OperationTimeout = 15000 }, ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60000 }, }; await cfg.Validate(ApplicationType.Client); cfg.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true; var instance = new ApplicationInstance { ApplicationConfiguration = cfg, ApplicationType = ApplicationType.Client }; await instance.CheckApplicationInstanceCertificate(true, CertificateFactory.DefaultKeySize); var selected = CoreClientUtils.SelectEndpoint(cfg, _endpoint, useSecurity: false); var endpointConfig = EndpointConfiguration.Create(cfg); var configuredEndpoint = new ConfiguredEndpoint(null, selected, endpointConfig); return await Session.Create(cfg, configuredEndpoint, false, "OtOpcUaWalkerTestClientSession", 60000, new UserIdentity(new AnonymousIdentityToken()), null); } /// /// Driver that registers into the host + implements DiscoverAsync as a no-op. The /// walker is the sole source of address-space content; if the UNS folders appear /// under browse, they came from the wire-in (not from the driver's own discovery). /// private sealed class EmptyDriver : IDriver, ITagDiscovery, IReadable { public EmptyDriver(string id) { DriverInstanceId = id; } public string DriverInstanceId { get; } public string DriverType => "EmptyForWalkerTest"; public Task InitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask; public Task ReinitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask; public Task ShutdownAsync(CancellationToken ct) => Task.CompletedTask; public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null); public long GetMemoryFootprint() => 0; public Task FlushOptionalCachesAsync(CancellationToken ct) => Task.CompletedTask; public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken ct) => Task.CompletedTask; public Task> ReadAsync( IReadOnlyList fullReferences, CancellationToken cancellationToken) { var now = DateTime.UtcNow; IReadOnlyList result = fullReferences.Select(_ => new DataValueSnapshot(0, 0u, now, now)).ToArray(); return Task.FromResult(result); } } }