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.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.Hosting; using ZB.MOM.WW.OtOpcUa.Server.OpcUa; using ZB.MOM.WW.OtOpcUa.Server.Security; namespace ZB.MOM.WW.OtOpcUa.Server.Tests; [Trait("Category", "Integration")] public sealed class OpcUaServerIntegrationTests : IAsyncLifetime { // Use a non-default port + per-test-run PKI root to avoid colliding with anything else // running on the box (a live v1 Host or a developer's previous run). private static readonly int Port = 48400 + Random.Shared.Next(0, 99); private readonly string _endpoint = $"opc.tcp://localhost:{Port}/OtOpcUaTest"; private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-test-{Guid.NewGuid():N}"); private DriverHost _driverHost = null!; private OpcUaApplicationHost _server = null!; private FakeDriver _driver = null!; public async ValueTask InitializeAsync() { _driverHost = new DriverHost(); _driver = new FakeDriver(); await _driverHost.RegisterAsync(_driver, "{}", CancellationToken.None); var options = new OpcUaServerOptions { EndpointUrl = _endpoint, ApplicationName = "OtOpcUaTest", ApplicationUri = "urn:OtOpcUa:Server:Test", PkiStoreRoot = _pkiRoot, AutoAcceptUntrustedClientCertificates = true, }; _server = new OpcUaApplicationHost(options, _driverHost, new DenyAllUserAuthenticator(), NullLoggerFactory.Instance, NullLogger.Instance); 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 Client_can_connect_and_browse_driver_subtree() { using var session = await OpenSessionAsync(); // Browse the driver subtree registered under ObjectsFolder. FakeDriver registers one // folder ("TestFolder") with one variable ("Var1"), so we expect to see our driver's // root folder plus standard UA children. var rootRef = new NodeId("fake", (ushort)session.NamespaceUris.GetIndex("urn:OtOpcUa:fake")); session.Browse(null, null, rootRef, 0, BrowseDirection.Forward, ReferenceTypeIds.HierarchicalReferences, true, (uint)NodeClass.Object | (uint)NodeClass.Variable, out _, out var references); references.Count.ShouldBeGreaterThan(0); references.ShouldContain(r => r.BrowseName.Name == "TestFolder"); } [Fact] public async Task Client_can_read_a_driver_variable_through_the_node_manager() { using var session = await OpenSessionAsync(); var nsIndex = (ushort)session.NamespaceUris.GetIndex("urn:OtOpcUa:fake"); var varNodeId = new NodeId("TestFolder.Var1", nsIndex); var dv = session.ReadValue(varNodeId); dv.ShouldNotBeNull(); // FakeDriver.ReadAsync returns 42 as the value. dv.Value.ShouldBe(42); } private async Task OpenSessionAsync() { var cfg = new ApplicationConfiguration { ApplicationName = "OtOpcUaTestClient", ApplicationUri = "urn:OtOpcUa:TestClient", ApplicationType = ApplicationType.Client, SecurityConfiguration = new SecurityConfiguration { ApplicationCertificate = new CertificateIdentifier { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-own"), SubjectName = "CN=OtOpcUaTestClient", }, 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); // Let the client fetch the live endpoint description from the running server so the // UserTokenPolicy it signs with matches what the server actually advertised (including // the PolicyId = "Anonymous" the server sets). 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, "OtOpcUaTestClientSession", 60000, new UserIdentity(new AnonymousIdentityToken()), null); } /// /// Minimum driver that implements enough of IDriver + ITagDiscovery + IReadable to drive /// the integration test. Returns a single folder with one variable that reads as 42. /// private sealed class FakeDriver : IDriver, ITagDiscovery, IReadable { public string DriverInstanceId => "fake"; public string DriverType => "Fake"; 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) { var folder = builder.Folder("TestFolder", "TestFolder"); folder.Variable("Var1", "Var1", new DriverAttributeInfo( "TestFolder.Var1", DriverDataType.Int32, false, null, SecurityClassification.FreeAccess, false, IsAlarm: false)); return Task.CompletedTask; } public Task> ReadAsync( IReadOnlyList fullReferences, CancellationToken cancellationToken) { var now = DateTime.UtcNow; IReadOnlyList result = fullReferences.Select(_ => new DataValueSnapshot(42, 0u, now, now)).ToArray(); return Task.FromResult(result); } } }