mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-22 01:26:20 +08:00
9b034f8291
# Description Nullable reference types are now enabled once, in `common.props`, and every project in the repository builds warning-clean with them. **Configuration** - `<Nullable>enable</Nullable>` lives in `common.props`. All per-project `<Nullable>` settings are removed: 178 project files (enable, annotations, and the `disable` in the three Aot test projects). - 419 per-file `#nullable enable`/`restore` directives and the 4 `#nullable disable` directives are removed. - Kept on purpose: - The 5 `<auto-generated>` schema files (`UANodeSet.cs`, `UA Type Dictionary.cs`, `OPCBinarySchema.cs`, `SecuredApplication.cs`, `PInvokeHelper.cs`) keep `#nullable enable`. The compiler ignores the project setting in generated code, so the directive is required there. - `tests/Opc.Ua.Interop.LegacyPeer` keeps `disable`. It builds against the 1.5.378 packages and does not import the repository props. - `docs/DeveloperGuide.md` and `.github/copilot-instructions.md` now describe the central setting. **Migration** (the 43 unset and 13 annotations-only projects, roughly 10k diagnostics) - **Production code: annotated at the declarations.** Covers `tools/Opc.Ua.SourceGeneration.Core`, `src/Opc.Ua.Lds.Server` and the LDS sample. - Members, parameters and returns are nullable where null actually flows. - Null-check helpers carry `[NotNullWhen]` (`IsNull`, `IsValidXmlName`, `IsMethodTypeDesign`). - `CoreUtils.Format`, `Utils.Format`, `ITemplateWriter.WriteLine` take `params object?[]`. `Template.AddReplacement` takes `string?` (rendering already skips null replacements). - `IRegisteredServerStore.RegisterAsync` returns `Task<RegistrationEntry?>`: it returns null when a registration is removed. - Dereferences backed by an invariant (for example, the validated model) use `!`, so runtime behaviour is unchanged. - **Behavioural changes in production code** (small, deliberate): - `NodeSetToModelDesign` throws `InvalidOperationException` when the file is not a NodeSet. Before, it threw a NullReferenceException on the next line. - `ModelDesignValidator` starts its lookup tables empty instead of null. A redundant `if (m_nodes == null) return;` guard is gone. - **Test code: minimal annotations.** - `?` on values that can be null, `!` where the test asserts non-null. - `= null!` for fixture members assigned in SetUp. - `TryGetStructure<T>(out T? x)` where the inferred `T?` broke the `IEncodeable` constraint. - A few redundant `== null` checks on structs (`NodeId`, `ArrayOf<T>`) folded into the adjacent `IsNull`/`Count` checks. **Notes for reviewers** - The diff is large (~1,500 files) but almost entirely mechanical: `?`, `!` and directive removal. The non-annotation changes are listed above. - Known follow-up, not done here: `IModelDesign.Namespaces/TargetNamespace/NamespaceUris` stay non-nullable even though the validator returns null before it loads a dictionary. Making them nullable would ripple through every generator call site. ## Related Issues - Fixes #4439 ## Checklist - [ ] I have signed the [CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf) and read the [CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md) doc. - [ ] I have added tests that prove my fix is effective or that my feature works and increased code coverage. - [x] I have added all necessary documentation. - [x] I have verified that my changes do not introduce (new) build or analyzer warnings. - [ ] I ran **all** tests locally using the **UA.slnx** solution against at least .net **framework** and .net **10**, and all passed. - [ ] I fixed **all** failing and flaky tests in the CI pipelines and **all** CodeQL warnings. - [ ] I have addressed **all** PR feedback received. The "no new warnings" box covers net10.0 and net48: full non-incremental builds of `UA.slnx`, `tools/SourceGeneration.slnx`, `tools/MigrationAnalyzer.slnx` and the Aot test projects finished with 0 warnings and 0 errors under the repository's warnings-as-errors settings. net9.0 and net8.0 also had no nullable diagnostics, but they were not rebuilt after the final fixes. Tests have not been run locally yet. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2270 lines
112 KiB
C#
2270 lines
112 KiB
C#
/* ========================================================================
|
|
* 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.Concurrent;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Runtime.CompilerServices;
|
|
using System.Text;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Hosting;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Microsoft.Extensions.Options;
|
|
using Microsoft.Extensions.Time.Testing;
|
|
using Moq;
|
|
using NUnit.Framework;
|
|
using Opc.Ua.Bindings;
|
|
using Opc.Ua.Identity;
|
|
using Opc.Ua.Security.Certificates;
|
|
using Opc.Ua.Server.AliasNames;
|
|
using Opc.Ua.Server.Hosting;
|
|
using Opc.Ua.Tests;
|
|
|
|
namespace Opc.Ua.Server.Tests.Hosting
|
|
{
|
|
/// <summary>
|
|
/// Exercises composition on the default hosted server, with only its network transport replaced.
|
|
/// </summary>
|
|
[TestFixture]
|
|
[Category("Hosting")]
|
|
[Category("Server")]
|
|
[SetCulture("en-US")]
|
|
[SetUICulture("en-US")]
|
|
[NonParallelizable]
|
|
public sealed class ServerCompositionHostingTests
|
|
{
|
|
[Test]
|
|
public void CompositionOverloadsRejectNullBuilders()
|
|
{
|
|
IOpcUaServerBuilder builder = null!;
|
|
var authenticator = new AnonymousAuthenticator();
|
|
IAsyncNodeManagerFactory asyncFactory = Mock.Of<IAsyncNodeManagerFactory>();
|
|
INodeManagerFactory syncFactory = Mock.Of<INodeManagerFactory>();
|
|
Action[] calls =
|
|
[
|
|
() => builder.ConfigureServerProperties(_ => { }),
|
|
() => builder.ConfigureResources(_ => { }),
|
|
() => builder.ConfigureResources((_, _) => { }),
|
|
() => builder.AddIdentityAuthenticator(authenticator),
|
|
() => builder.AddIdentityAuthenticator((_, _) => authenticator),
|
|
() => builder.AddStartupTask<CompositionStartupTask>(),
|
|
() => builder.AddStartupTask((_, _, _) => default),
|
|
() => builder.AddNodeManager(asyncFactory),
|
|
() => builder.AddNodeManager(syncFactory),
|
|
() => builder.AddNodeManagers((_, _) => []),
|
|
() => builder.ConfigureAliasNames(_ => { })
|
|
];
|
|
|
|
foreach (Action call in calls)
|
|
{
|
|
Assert.That(call, Throws.ArgumentNullException
|
|
.With.Property(nameof(ArgumentNullException.ParamName)).EqualTo("builder"));
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public void CompositionOverloadsRejectNullArgumentsWithoutRegisteringServices()
|
|
{
|
|
IOpcUaServerBuilder builder = CreateBuilder();
|
|
int count = builder.Services.Count;
|
|
(Action Call, string Parameter)[] calls =
|
|
[
|
|
(() => builder.ConfigureServerProperties(null!), "configure"),
|
|
(() => builder.ConfigureResources((Action<ResourceManager>)null!), "configure"),
|
|
(() => builder.ConfigureResources((Action<IServiceProvider, ResourceManager>)null!), "configure"),
|
|
(() => builder.AddIdentityAuthenticator(instance: null!), "instance"),
|
|
(() => builder.AddIdentityAuthenticator(factory: null!), "factory"),
|
|
(() => builder.AddStartupTask(null!), "callback"),
|
|
(() => builder.AddNodeManager((IAsyncNodeManagerFactory)null!), "factory"),
|
|
(() => builder.AddNodeManager((INodeManagerFactory)null!), "factory"),
|
|
(() => builder.AddNodeManagers(null!), "factories"),
|
|
(() => builder.ConfigureAliasNames(null!), "configure")
|
|
];
|
|
|
|
foreach ((Action call, string parameter) in calls)
|
|
{
|
|
Assert.That(call, Throws.ArgumentNullException
|
|
.With.Property(nameof(ArgumentNullException.ParamName)).EqualTo(parameter));
|
|
Assert.That(builder.Services, Has.Count.EqualTo(count));
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public void CompositionOverloadsReturnOriginalBuilderAndDeferCallbacks()
|
|
{
|
|
IOpcUaServerBuilder builder = CreateBuilder();
|
|
int calls = 0;
|
|
var authenticator = new AnonymousAuthenticator();
|
|
IAsyncNodeManagerFactory asyncFactory = Mock.Of<IAsyncNodeManagerFactory>();
|
|
INodeManagerFactory syncFactory = Mock.Of<INodeManagerFactory>();
|
|
|
|
Assert.That(builder.ConfigureServerProperties(_ => calls++), Is.SameAs(builder));
|
|
Assert.That(builder.ConfigureResources(_ => calls++), Is.SameAs(builder));
|
|
Assert.That(builder.ConfigureResources((_, _) => calls++), Is.SameAs(builder));
|
|
Assert.That(builder.AddIdentityAuthenticator(authenticator), Is.SameAs(builder));
|
|
Assert.That(builder.AddIdentityAuthenticator((_, _) =>
|
|
{
|
|
calls++;
|
|
return authenticator;
|
|
}), Is.SameAs(builder));
|
|
Assert.That(builder.AddStartupTask<CompositionStartupTask>(), Is.SameAs(builder));
|
|
Assert.That(builder.AddStartupTask((_, _, _) =>
|
|
{
|
|
calls++;
|
|
return default;
|
|
}), Is.SameAs(builder));
|
|
Assert.That(builder.AddNodeManager(asyncFactory), Is.SameAs(builder));
|
|
Assert.That(builder.AddNodeManager(syncFactory), Is.SameAs(builder));
|
|
Assert.That(builder.AddNodeManagers((_, _) =>
|
|
{
|
|
calls++;
|
|
return [];
|
|
}), Is.SameAs(builder));
|
|
Assert.That(builder.ConfigureAliasNames(_ => calls++), Is.SameAs(builder));
|
|
Assert.That(calls, Is.Zero);
|
|
|
|
using ServiceProvider provider = builder.Services.BuildServiceProvider();
|
|
OpcUaServerNodeManagerRegistration[] registrations = [.. provider.GetServices<OpcUaServerNodeManagerRegistration>()];
|
|
Assert.That(registrations, Has.Length.EqualTo(3));
|
|
Assert.That(registrations[0].AsyncFactory, Is.SameAs(asyncFactory));
|
|
Assert.That(registrations[0].SyncFactory, Is.Null);
|
|
Assert.That(registrations[1].SyncFactory, Is.SameAs(syncFactory));
|
|
Assert.That(registrations[1].AsyncFactory, Is.Null);
|
|
Assert.That(calls, Is.Zero, "Resolving wrappers must not execute configuration-aware callbacks.");
|
|
}
|
|
|
|
[Test]
|
|
public void IdentityFactoryRejectsNullResultAtResolution()
|
|
{
|
|
IOpcUaServerBuilder builder = CreateBuilder();
|
|
int calls = 0;
|
|
builder.AddIdentityAuthenticator((_, _) =>
|
|
{
|
|
calls++;
|
|
return null!;
|
|
});
|
|
using ServiceProvider provider = builder.Services.BuildServiceProvider();
|
|
OpcUaServerIdentityAuthenticatorRegistration registration = provider
|
|
.GetServices<OpcUaServerIdentityAuthenticatorRegistration>().Last();
|
|
|
|
Assert.That(calls, Is.Zero);
|
|
Assert.That(
|
|
() => registration.CreateAuthenticators(provider, null).ToArray(),
|
|
Throws.InvalidOperationException.With.Message.Contains("returned null"));
|
|
Assert.That(calls, Is.EqualTo(1));
|
|
}
|
|
|
|
[Test]
|
|
public void AuthenticatorInstanceKeepsItsIdentityAndRemainsCallerOwned()
|
|
{
|
|
var authenticator = new Mock<IUserTokenAuthenticator>(MockBehavior.Strict);
|
|
authenticator.As<IDisposable>();
|
|
IOpcUaServerBuilder builder = CreateBuilder().AddIdentityAuthenticator(authenticator.Object);
|
|
using (ServiceProvider provider = builder.Services.BuildServiceProvider())
|
|
{
|
|
OpcUaServerIdentityAuthenticatorRegistration registration = provider
|
|
.GetServices<OpcUaServerIdentityAuthenticatorRegistration>().Last();
|
|
IUserTokenAuthenticator[] created = [.. registration.CreateAuthenticators(provider, null)];
|
|
|
|
Assert.That(created, Has.Length.EqualTo(1));
|
|
Assert.That(created[0], Is.SameAs(authenticator.Object));
|
|
Assert.That(registration.IsFallback, Is.False);
|
|
}
|
|
authenticator.As<IDisposable>().Verify(instance => instance.Dispose(), Times.Never);
|
|
}
|
|
|
|
[Test]
|
|
public async Task StartupDelegateForwardsServicesContextCancellationAndCompletionAsync()
|
|
{
|
|
IOpcUaServerBuilder builder = CreateBuilder();
|
|
var dependency = new CompositionObservations();
|
|
TaskCompletionSource<bool> release = NewSignal<bool>();
|
|
TaskCompletionSource<bool> entered = NewSignal<bool>();
|
|
IServerContext context = Mock.Of<IServerContext>();
|
|
using var cancellation = new CancellationTokenSource();
|
|
builder.Services.AddSingleton(dependency);
|
|
builder.AddStartupTask(async (services, server, ct) =>
|
|
{
|
|
Assert.That(services.GetRequiredService<CompositionObservations>(), Is.SameAs(dependency));
|
|
Assert.That(server, Is.SameAs(context));
|
|
Assert.That(ct, Is.EqualTo(cancellation.Token));
|
|
entered.TrySetResult(true);
|
|
await release.Task.ConfigureAwait(false);
|
|
dependency.Events.Enqueue("completed");
|
|
});
|
|
using ServiceProvider provider = builder.Services.BuildServiceProvider();
|
|
IServerStartupTask task = provider.GetServices<IServerStartupTask>().Single();
|
|
|
|
Task running = task.OnServerStartedAsync(context, cancellation.Token).AsTask();
|
|
try
|
|
{
|
|
await AwaitBoundedAsync(entered.Task).ConfigureAwait(false);
|
|
Assert.That(running.IsCompleted, Is.False);
|
|
Assert.That(dependency.Events, Is.Empty);
|
|
}
|
|
finally
|
|
{
|
|
release.TrySetResult(true);
|
|
}
|
|
await AwaitBoundedAsync(running).ConfigureAwait(false);
|
|
Assert.That(dependency.Events, Is.EqualTo(s_completedEvents));
|
|
}
|
|
|
|
[Test]
|
|
public async Task HostedStartupTasksAreAwaitedInOrderAndGenericRegistrationIsIdempotentAsync()
|
|
{
|
|
var observations = new CompositionObservations();
|
|
TaskCompletionSource<bool> entered = NewSignal<bool>();
|
|
TaskCompletionSource<bool> release = NewSignal<bool>();
|
|
var fixture = HostedFixture.Create(builder =>
|
|
{
|
|
builder.Services.AddSingleton(observations);
|
|
builder.Services.AddSingleton<IServerStartupTask>(new ExistingStartupTask(observations));
|
|
builder.AddStartupTask<CompositionStartupTask>();
|
|
builder.AddStartupTask<CompositionStartupTask>();
|
|
builder.AddStartupTask(async (_, context, ct) =>
|
|
{
|
|
Assert.That(context.CurrentState, Is.EqualTo(ServerState.Running));
|
|
Assert.That(ct.CanBeCanceled, Is.True);
|
|
observations.Events.Enqueue("delegate-entered");
|
|
entered.TrySetResult(true);
|
|
await release.Task.ConfigureAwait(false);
|
|
observations.Events.Enqueue("delegate-completed");
|
|
});
|
|
builder.AddStartupTask((_, _, _) =>
|
|
{
|
|
observations.Events.Enqueue("last");
|
|
return default;
|
|
});
|
|
});
|
|
await using ConfiguredAsyncDisposable cleanup = fixture.ConfigureAwait(false);
|
|
|
|
Task starting = fixture.StartAsync();
|
|
try
|
|
{
|
|
await AwaitBoundedAsync(entered.Task).ConfigureAwait(false);
|
|
Assert.That(starting.IsCompleted, Is.False);
|
|
Assert.That(observations.Events, Is.EqualTo(s_enteredEvents));
|
|
}
|
|
finally
|
|
{
|
|
release.TrySetResult(true);
|
|
}
|
|
await AwaitBoundedAsync(starting).ConfigureAwait(false);
|
|
|
|
Assert.That(observations.Events, Is.EqualTo(s_startupEvents));
|
|
Assert.That(observations.StartupCount, Is.EqualTo(1));
|
|
Assert.That(observations.Context, Is.SameAs(fixture.Context));
|
|
HistoryServerCapabilitiesState history = fixture.Context
|
|
.FindNodeManagers<IDiagnosticsNodeManager>().Single()
|
|
.FindPredefinedNode<HistoryServerCapabilitiesState>(observations.HistoryNodeId);
|
|
Assert.That(history.MaxReturnDataValues!.Value, Is.EqualTo(73u));
|
|
Assert.That(history.MaxReturnEventValues!.Value, Is.EqualTo(29u));
|
|
}
|
|
|
|
[TestCase(false)]
|
|
[TestCase(true)]
|
|
public async Task BuildInfoDefaultsComeFromEffectiveApplicationAsync(bool sharedApplication)
|
|
{
|
|
var fixture = new HostedFixture((services, root) =>
|
|
{
|
|
IOpcUaBuilder builder = services.AddOpcUa();
|
|
if (sharedApplication)
|
|
{
|
|
builder.ConfigureApplication(options =>
|
|
{
|
|
options.ApplicationName = "SharedComposition";
|
|
options.ApplicationUri = "urn:localhost:SharedComposition";
|
|
options.ProductUri = "urn:composition:shared-product";
|
|
options.PkiRoot = root;
|
|
});
|
|
}
|
|
return builder.AddServer(options => ConfigureOptions(options, root));
|
|
});
|
|
await using ConfiguredAsyncDisposable cleanup = fixture.ConfigureAwait(false);
|
|
await fixture.StartAsync().ConfigureAwait(false);
|
|
|
|
BuildInfo info = ReadBuildInfo(fixture.Context);
|
|
Assert.That(info.ProductName,
|
|
Is.EqualTo(sharedApplication ? "SharedComposition" : "CompositionServer"));
|
|
Assert.That(info.ProductUri,
|
|
Is.EqualTo(sharedApplication ? "urn:composition:shared-product" : "urn:composition:product"));
|
|
Assert.That(info.SoftwareVersion, Is.EqualTo(Utils.GetAssemblySoftwareVersion()));
|
|
Assert.That(info.BuildNumber, Is.EqualTo(Utils.GetAssemblyBuildNumber()));
|
|
Assert.That(info.ManufacturerName, Is.Empty);
|
|
Assert.That(info.BuildDate, Is.EqualTo((DateTimeUtc)DateTime.MinValue));
|
|
Assert.That(fixture.Context.CurrentState, Is.EqualTo(ServerState.Running));
|
|
Assert.That(fixture.Server, Is.InstanceOf<ReverseConnectServer>());
|
|
}
|
|
|
|
[Test]
|
|
public async Task ConfiguredServerPropertiesPopulateRuntimeBuildInfoWithoutChangingConfigurationAsync()
|
|
{
|
|
var buildDate = new DateTime(2025, 11, 23, 14, 15, 16, DateTimeKind.Utc);
|
|
var fixture = HostedFixture.Create(builder =>
|
|
builder.ConfigureServerProperties(properties =>
|
|
{
|
|
properties.ProductName = "Composition Product";
|
|
properties.ProductUri = "urn:composition:published-product";
|
|
properties.ManufacturerName = "Composition Manufacturer";
|
|
properties.SoftwareVersion = "7.8.9";
|
|
properties.BuildNumber = "build-123";
|
|
properties.BuildDate = buildDate;
|
|
}));
|
|
await using ConfiguredAsyncDisposable cleanup = fixture.ConfigureAwait(false);
|
|
await fixture.StartAsync().ConfigureAwait(false);
|
|
|
|
BuildInfo info = ReadBuildInfo(fixture.Context);
|
|
Assert.That(info.ProductName, Is.EqualTo("Composition Product"));
|
|
Assert.That(info.ProductUri, Is.EqualTo("urn:composition:published-product"));
|
|
Assert.That(info.ManufacturerName, Is.EqualTo("Composition Manufacturer"));
|
|
Assert.That(info.SoftwareVersion, Is.EqualTo("7.8.9"));
|
|
Assert.That(info.BuildNumber, Is.EqualTo("build-123"));
|
|
Assert.That(info.BuildDate, Is.EqualTo((DateTimeUtc)buildDate));
|
|
Assert.That(fixture.Configuration.ApplicationName, Is.EqualTo("CompositionServer"));
|
|
Assert.That(fixture.Configuration.ProductUri, Is.EqualTo("urn:composition:product"));
|
|
Assert.That(ReadValue(fixture.Context, VariableIds.Server_ServerStatus_BuildInfo_ProductName)
|
|
.WrappedValue.GetString(), Is.EqualTo(info.ProductName));
|
|
}
|
|
|
|
[Test]
|
|
public async Task DirectServerPropertiesWinOptionsWithoutMutatingEitherRegistrationAsync()
|
|
{
|
|
var direct = new ServerProperties
|
|
{
|
|
ProductName = string.Empty,
|
|
ProductUri = string.Empty,
|
|
ManufacturerName = "Instance Manufacturer",
|
|
BuildNumber = "instance-build"
|
|
};
|
|
var fixture = HostedFixture.Create(builder =>
|
|
{
|
|
builder.ConfigureServerProperties(properties =>
|
|
{
|
|
properties.ProductName = "Ignored Option Name";
|
|
properties.ProductUri = "urn:ignored:options";
|
|
properties.SoftwareVersion = "99.99";
|
|
properties.ManufacturerName = "Ignored Option Manufacturer";
|
|
});
|
|
builder.Services.AddSingleton(direct);
|
|
});
|
|
await using ConfiguredAsyncDisposable cleanup = fixture.ConfigureAwait(false);
|
|
await fixture.StartAsync().ConfigureAwait(false);
|
|
|
|
BuildInfo info = ReadBuildInfo(fixture.Context);
|
|
Assert.That(info.ProductName, Is.EqualTo("CompositionServer"));
|
|
Assert.That(info.ProductUri, Is.EqualTo("urn:composition:product"));
|
|
Assert.That(info.ManufacturerName, Is.EqualTo("Instance Manufacturer"));
|
|
Assert.That(info.BuildNumber, Is.EqualTo("instance-build"));
|
|
Assert.That(info.SoftwareVersion, Is.EqualTo(Utils.GetAssemblySoftwareVersion()),
|
|
"Unset instance fields use runtime defaults, not fields from the losing options object.");
|
|
Assert.That(direct.ProductName, Is.Empty);
|
|
Assert.That(direct.ProductUri, Is.Empty);
|
|
Assert.That(direct.SoftwareVersion, Is.Null.Or.Empty);
|
|
ServerProperties options = fixture.Services.GetRequiredService<IOptions<ServerProperties>>().Value;
|
|
Assert.That(options.ProductName, Is.EqualTo("Ignored Option Name"));
|
|
Assert.That(options.ProductUri, Is.EqualTo("urn:ignored:options"));
|
|
Assert.That(options.SoftwareVersion, Is.EqualTo("99.99"));
|
|
}
|
|
|
|
[TestCase(false)]
|
|
[TestCase(true)]
|
|
public async Task LoadedConfigurationOverridesDriveBuildInfoAndDeferredFactoriesAsync(bool useStream)
|
|
{
|
|
using var factory = new MarkerNodeManagerFactory("urn:composition:loaded", 42);
|
|
ApplicationConfiguration seenConfiguration = null!;
|
|
int callbackCount = 0;
|
|
int overrideCount = 0;
|
|
var fixture = new HostedFixture((services, root) =>
|
|
{
|
|
IOpcUaBuilder rootBuilder = services.AddOpcUa().ConfigureApplication(options =>
|
|
{
|
|
options.ApplicationName = "Ignored Shared Name";
|
|
options.ProductUri = "urn:ignored:shared";
|
|
options.PkiRoot = root;
|
|
});
|
|
string xml = CreateConfigurationXml(root);
|
|
IOpcUaServerBuilder serverBuilder;
|
|
if (useStream)
|
|
{
|
|
serverBuilder = rootBuilder.AddServer(
|
|
new MemoryStream(Encoding.UTF8.GetBytes(xml)),
|
|
OverrideConfiguration);
|
|
}
|
|
else
|
|
{
|
|
string path = Path.Combine(root, "server.xml");
|
|
File.WriteAllText(path, xml);
|
|
serverBuilder = rootBuilder.AddServer(path, OverrideConfiguration);
|
|
}
|
|
serverBuilder.Services.Configure<OpcUaServerOptions>(options =>
|
|
{
|
|
options.ApplicationName = "Ignored Options Name";
|
|
options.ProductUri = "urn:ignored:options";
|
|
});
|
|
return serverBuilder.AddNodeManagers((services, configuration) =>
|
|
{
|
|
Assert.That(services.GetService<ApplicationConfiguration>(), Is.Null);
|
|
Assert.That(overrideCount, Is.EqualTo(1));
|
|
seenConfiguration = configuration;
|
|
callbackCount++;
|
|
return [factory];
|
|
});
|
|
});
|
|
await using ConfiguredAsyncDisposable cleanup = fixture.ConfigureAwait(false);
|
|
|
|
Assert.That(callbackCount, Is.Zero);
|
|
Assert.That(factory.CreateCount, Is.Zero);
|
|
await fixture.StartAsync().ConfigureAwait(false);
|
|
|
|
BuildInfo info = ReadBuildInfo(fixture.Context);
|
|
Assert.That(info.ProductName, Is.EqualTo("Loaded Override"));
|
|
Assert.That(info.ProductUri, Is.EqualTo("urn:composition:loaded-override"));
|
|
Assert.That(seenConfiguration, Is.SameAs(fixture.Configuration));
|
|
Assert.That(factory.Configuration, Is.SameAs(seenConfiguration));
|
|
Assert.That(seenConfiguration.TransportQuotas!.MaxStringLength, Is.EqualTo(654321));
|
|
Assert.That(seenConfiguration.ServerConfiguration!.MaxSessionCount, Is.EqualTo(77));
|
|
Assert.That(callbackCount, Is.EqualTo(1));
|
|
Assert.That(factory.CreateCount, Is.EqualTo(1));
|
|
AssertMarker(fixture.Context, factory.NamespacesUris[0], 42);
|
|
|
|
void OverrideConfiguration(ApplicationConfiguration configuration)
|
|
{
|
|
overrideCount++;
|
|
Assert.That(configuration.ApplicationName, Is.EqualTo("XmlComposition"));
|
|
configuration.ApplicationName = "Loaded Override";
|
|
configuration.ProductUri = "urn:composition:loaded-override";
|
|
}
|
|
}
|
|
|
|
[TestCase(0, false)]
|
|
[TestCase(0, true)]
|
|
[TestCase(1, false)]
|
|
[TestCase(3, false)]
|
|
public async Task ConfigurationFactoryCanProduceZeroOneOrManyManagersInOrderAsync(
|
|
int count,
|
|
bool returnNullArray)
|
|
{
|
|
MarkerNodeManagerFactory[] factories = [.. Enumerable.Range(0, count)
|
|
.Select(index => new MarkerNodeManagerFactory("urn:composition:factory:" + index, 100 + index))];
|
|
int callbackCount = 0;
|
|
var fixture = HostedFixture.Create(builder =>
|
|
builder.AddNodeManagers((services, configuration) =>
|
|
{
|
|
Assert.That(configuration.ApplicationName, Is.EqualTo("CompositionServer"));
|
|
Assert.That(services.GetService<ApplicationConfiguration>(), Is.Null);
|
|
callbackCount++;
|
|
return returnNullArray
|
|
? default
|
|
: factories.Cast<IAsyncNodeManagerFactory>().ToArray().ToArrayOf();
|
|
}));
|
|
await using ConfiguredAsyncDisposable cleanup = fixture.ConfigureAwait(false);
|
|
|
|
Assert.That(callbackCount, Is.Zero);
|
|
await fixture.StartAsync().ConfigureAwait(false);
|
|
|
|
Assert.That(callbackCount, Is.EqualTo(1));
|
|
Assert.That(fixture.Context.FindNodeManagers<MarkerNodeManager>().Count(), Is.EqualTo(count));
|
|
int previousIndex = 0;
|
|
for (int index = 0; index < count; index++)
|
|
{
|
|
MarkerNodeManagerFactory factory = factories[index];
|
|
Assert.That(factory.CreateCount, Is.EqualTo(1));
|
|
Assert.That(factory.Configuration, Is.SameAs(fixture.Configuration));
|
|
AssertMarker(fixture.Context, factory.NamespacesUris[0], 100 + index);
|
|
int namespaceIndex = fixture.Context.DefaultSystemContext.NamespaceUris
|
|
.GetIndex(factory.NamespacesUris[0]);
|
|
Assert.That(namespaceIndex, Is.GreaterThan(previousIndex));
|
|
previousIndex = namespaceIndex;
|
|
}
|
|
Assert.That(ReadBuildInfo(fixture.Context).ProductName, Is.EqualTo("CompositionServer"));
|
|
}
|
|
|
|
[Test]
|
|
public async Task GenericFactoryIsConstructedAfterConfigurationInsteadOfHostedServiceResolutionAsync()
|
|
{
|
|
var observations = new CompositionObservations();
|
|
var fixture = new HostedFixture((services, root) =>
|
|
{
|
|
services.AddSingleton(observations);
|
|
return services.AddOpcUa()
|
|
.AddServer(
|
|
new MemoryStream(Encoding.UTF8.GetBytes(CreateConfigurationXml(root))),
|
|
_ => observations.ConfigurationLoaded = true)
|
|
.AddNodeManager<DelayedNodeManagerFactory>();
|
|
});
|
|
await using ConfiguredAsyncDisposable cleanup = fixture.ConfigureAwait(false);
|
|
|
|
Assert.That(observations.FactoryConstructions, Is.Zero);
|
|
Assert.That(observations.ConfigurationLoaded, Is.False);
|
|
await fixture.StartAsync().ConfigureAwait(false);
|
|
|
|
Assert.That(observations.FactoryConstructions, Is.EqualTo(1));
|
|
Assert.That(observations.FactorySawLoadedConfiguration, Is.True);
|
|
Assert.That(ReadBuildInfo(fixture.Context).ProductName, Is.EqualTo("XmlComposition"));
|
|
Assert.That(ReadBuildInfo(fixture.Context).ProductUri, Is.EqualTo("urn:composition:xml-product"));
|
|
AssertMarker(fixture.Context, DelayedNodeManagerFactory.NamespaceUri, 51);
|
|
Assert.That(fixture.Services.GetRequiredService<DelayedNodeManagerFactory>(),
|
|
Is.SameAs(observations.Factory));
|
|
}
|
|
|
|
[Test]
|
|
public async Task SuppliedAsyncAndLegacyFactoriesCreateLiveNodesAndRemainCallerOwnedAsync()
|
|
{
|
|
using var asyncFactory = new MarkerNodeManagerFactory("urn:composition:async-instance", 11);
|
|
var syncFactory = new Mock<INodeManagerFactory>(MockBehavior.Strict);
|
|
syncFactory.As<IDisposable>();
|
|
syncFactory.SetupGet(factory => factory.NamespacesUris).Returns(["urn:composition:sync-instance"]);
|
|
LegacyMarkerNodeManager legacyManager = null!;
|
|
syncFactory.Setup(factory => factory.Create(
|
|
It.IsAny<IServerInternal>(), It.IsAny<ApplicationConfiguration>()))
|
|
.Returns((IServerInternal server, ApplicationConfiguration configuration) =>
|
|
{
|
|
legacyManager = new LegacyMarkerNodeManager(server, configuration);
|
|
return legacyManager;
|
|
});
|
|
var fixture = HostedFixture.Create(builder => builder
|
|
.AddNodeManager(asyncFactory)
|
|
.AddNodeManager(syncFactory.Object));
|
|
await using (fixture.ConfigureAwait(false))
|
|
{
|
|
await fixture.StartAsync().ConfigureAwait(false);
|
|
AssertMarker(fixture.Context, "urn:composition:async-instance", 11);
|
|
AssertMarker(fixture.Context, "urn:composition:sync-instance", 22);
|
|
syncFactory.Verify(factory => factory.Create(
|
|
It.IsAny<IServerInternal>(), fixture.Configuration), Times.Once);
|
|
}
|
|
|
|
Assert.That(asyncFactory.CreateCount, Is.EqualTo(1));
|
|
Assert.That(asyncFactory.DisposeCount, Is.Zero);
|
|
Assert.That(asyncFactory.Manager.Disposed, Is.True);
|
|
Assert.That(legacyManager.Disposed, Is.True);
|
|
syncFactory.As<IDisposable>().Verify(factory => factory.Dispose(), Times.Never);
|
|
}
|
|
|
|
[Test]
|
|
public async Task NullFactoryElementAbortsBeforeAnyAddressSpaceOrStartupTaskAsync()
|
|
{
|
|
int starts = 0;
|
|
var fixture = HostedFixture.Create(builder => builder
|
|
.AddNodeManagers((_, _) => new ArrayOf<IAsyncNodeManagerFactory>(
|
|
new IAsyncNodeManagerFactory[] { null! }))
|
|
.AddStartupTask((_, _, _) =>
|
|
{
|
|
starts++;
|
|
return default;
|
|
}));
|
|
await using ConfiguredAsyncDisposable cleanup = fixture.ConfigureAwait(false);
|
|
|
|
Exception failure = await fixture.StartAndCaptureFailureAsync().ConfigureAwait(false);
|
|
|
|
Assert.That(failure, Is.TypeOf<InvalidOperationException>());
|
|
Assert.That(failure.Message, Does.Contain("null factory"));
|
|
Assert.That(starts, Is.Zero);
|
|
Assert.That(fixture.Transport.Server, Is.Null,
|
|
"The invalid collection must fail before service hosts or node managers are started.");
|
|
}
|
|
|
|
[Test]
|
|
public async Task StartupTaskFailureFaultsExecuteTaskStopsServerAndSkipsLaterTasksAsync()
|
|
{
|
|
var failure = new InvalidOperationException("composition-startup-failure");
|
|
var observations = new CompositionObservations();
|
|
using var factory = new MarkerNodeManagerFactory("urn:composition:failing-startup", 18);
|
|
var fixture = HostedFixture.Create(builder =>
|
|
{
|
|
builder.Services.AddSingleton(observations);
|
|
builder.AddNodeManager(factory);
|
|
builder.AddStartupTask((_, context, _) =>
|
|
{
|
|
observations.Context = context;
|
|
Assert.That(context.CurrentState, Is.EqualTo(ServerState.Running));
|
|
AssertMarker(context, factory.NamespacesUris[0], 18);
|
|
throw failure;
|
|
});
|
|
builder.AddStartupTask((_, _, _) =>
|
|
{
|
|
observations.StartupCount++;
|
|
return default;
|
|
});
|
|
});
|
|
await using ConfiguredAsyncDisposable cleanup = fixture.ConfigureAwait(false);
|
|
|
|
Exception actual = await fixture.StartAndCaptureFailureAsync().ConfigureAwait(false);
|
|
|
|
Assert.That(actual, Is.SameAs(failure));
|
|
Assert.That(fixture.ExecuteTask.IsFaulted, Is.True);
|
|
Assert.That(observations.StartupCount, Is.Zero);
|
|
Assert.That(factory.Manager.Disposed, Is.True);
|
|
Assert.That(factory.DisposeCount, Is.Zero);
|
|
fixture.Transport.Listener.Verify(listener => listener.CloseAsync(
|
|
It.IsAny<CancellationToken>()), Times.AtLeastOnce);
|
|
Assert.That(fixture.Logs.Errors, Does.Contain(failure));
|
|
}
|
|
|
|
[Test]
|
|
public async Task StoppingDuringStartupCancelsTheCallbackAndDisposesTheServerAsync()
|
|
{
|
|
TaskCompletionSource<bool> entered = NewSignal<bool>();
|
|
TaskCompletionSource<bool> cancelled = NewSignal<bool>();
|
|
using var factory = new MarkerNodeManagerFactory("urn:composition:cancelled-startup", 26);
|
|
int laterCalls = 0;
|
|
CancellationToken observedToken = default;
|
|
var fixture = HostedFixture.Create(builder => builder
|
|
.AddNodeManager(factory)
|
|
.AddStartupTask(async (_, context, ct) =>
|
|
{
|
|
observedToken = ct;
|
|
AssertMarker(context, factory.NamespacesUris[0], 26);
|
|
using CancellationTokenRegistration registration = ct.Register(() => cancelled.TrySetResult(true));
|
|
entered.TrySetResult(true);
|
|
await cancelled.Task.ConfigureAwait(false);
|
|
ct.ThrowIfCancellationRequested();
|
|
})
|
|
.AddStartupTask((_, _, _) =>
|
|
{
|
|
laterCalls++;
|
|
return default;
|
|
}));
|
|
await using ConfiguredAsyncDisposable cleanup = fixture.ConfigureAwait(false);
|
|
Task starting = fixture.StartAsync();
|
|
await AwaitBoundedAsync(entered.Task).ConfigureAwait(false);
|
|
|
|
await fixture.StopAsync().ConfigureAwait(false);
|
|
OperationCanceledException failure = null!;
|
|
try
|
|
{
|
|
await starting.ConfigureAwait(false);
|
|
}
|
|
catch (OperationCanceledException exception)
|
|
{
|
|
failure = exception;
|
|
}
|
|
|
|
Assert.That(failure, Is.Not.Null);
|
|
Assert.That(failure.CancellationToken, Is.EqualTo(observedToken));
|
|
Assert.That(observedToken.IsCancellationRequested, Is.True);
|
|
Assert.That(fixture.ExecuteTask.IsCanceled, Is.True);
|
|
Assert.That(laterCalls, Is.Zero);
|
|
Assert.That(factory.Manager.Disposed, Is.True);
|
|
fixture.Transport.Listener.Verify(listener => listener.CloseAsync(
|
|
It.IsAny<CancellationToken>()), Times.AtLeastOnce);
|
|
}
|
|
|
|
[Test]
|
|
public async Task NullAuthenticatorResultFaultsBeforeOpeningTransportAndDisposesServerAsync()
|
|
{
|
|
using var server = new DisposalTrackingServer(NUnitTelemetryContext.Create(isServer: true));
|
|
var serverFactory = new Mock<IOpcUaServerFactory>();
|
|
serverFactory.Setup(factory => factory.CreateServer(
|
|
It.IsAny<ITelemetryContext>(), It.IsAny<TimeProvider>())).Returns(server);
|
|
int authenticatorCalls = 0;
|
|
int starts = 0;
|
|
var fixture = HostedFixture.Create(builder =>
|
|
{
|
|
builder.Services.AddSingleton(serverFactory.Object);
|
|
builder.AddIdentityAuthenticator((_, _) =>
|
|
{
|
|
authenticatorCalls++;
|
|
return null!;
|
|
})
|
|
.AddStartupTask((_, _, _) =>
|
|
{
|
|
starts++;
|
|
return default;
|
|
});
|
|
});
|
|
await using ConfiguredAsyncDisposable cleanup = fixture.ConfigureAwait(false);
|
|
|
|
Exception failure = await fixture.StartAndCaptureFailureAsync().ConfigureAwait(false);
|
|
|
|
Assert.That(failure, Is.TypeOf<InvalidOperationException>());
|
|
Assert.That(failure.Message, Does.Contain("authenticator factory returned null"));
|
|
Assert.That(fixture.ExecuteTask.IsFaulted, Is.True);
|
|
Assert.That(authenticatorCalls, Is.EqualTo(1));
|
|
Assert.That(starts, Is.Zero);
|
|
serverFactory.Verify(factory => factory.CreateServer(
|
|
It.IsAny<ITelemetryContext>(), It.IsAny<TimeProvider>()), Times.Once);
|
|
Assert.That(fixture.Transport.Server, Is.Null,
|
|
"Authentication registration must succeed before transport startup.");
|
|
fixture.Transport.Listener.VerifyNoOtherCalls();
|
|
Assert.That(server.DisposeCount, Is.EqualTo(1),
|
|
"The constructed server must be disposed before ExecuteTask reports the startup failure.");
|
|
}
|
|
|
|
[Test]
|
|
public async Task CancelledHostStopWaitsForServerCleanupAsync()
|
|
{
|
|
TaskCompletionSource<bool> stoppingEntered = NewSignal<bool>();
|
|
TaskCompletionSource<bool> releaseCleanup = NewSignal<bool>();
|
|
var fixture = HostedFixture.Create();
|
|
await using ConfiguredAsyncDisposable cleanup = fixture.ConfigureAwait(false);
|
|
fixture.Transport.Listener.Setup(listener => listener.CloseAsync(It.IsAny<CancellationToken>()))
|
|
.Returns(() =>
|
|
{
|
|
stoppingEntered.TrySetResult(true);
|
|
return new ValueTask(releaseCleanup.Task);
|
|
});
|
|
await fixture.StartAsync().ConfigureAwait(false);
|
|
IHostedService hostedService = fixture.Services.GetServices<IHostedService>().Single();
|
|
using var expiredDeadline = new CancellationTokenSource();
|
|
expiredDeadline.Cancel();
|
|
|
|
Task stopping = hostedService.StopAsync(expiredDeadline.Token);
|
|
try
|
|
{
|
|
await AwaitBoundedAsync(stoppingEntered.Task).ConfigureAwait(false);
|
|
Assert.That(fixture.ExecuteTask.IsCompleted, Is.False);
|
|
Assert.That(stopping.IsCompleted, Is.False,
|
|
"The host must not dispose injected services while server cleanup still uses them.");
|
|
}
|
|
finally
|
|
{
|
|
releaseCleanup.TrySetResult(true);
|
|
await AwaitBoundedAsync(fixture.ExecuteTask).ConfigureAwait(false);
|
|
await stopping.ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public async Task CancelledStartupCanStopWithoutExecutingServerAsync()
|
|
{
|
|
var fixture = HostedFixture.Create();
|
|
await using ConfiguredAsyncDisposable cleanup = fixture.ConfigureAwait(false);
|
|
IHostedService hostedService = fixture.Services.GetServices<IHostedService>().Single();
|
|
using var cancellation = new CancellationTokenSource();
|
|
cancellation.Cancel();
|
|
|
|
await hostedService.StartAsync(cancellation.Token).ConfigureAwait(false);
|
|
await AwaitBoundedAsync(hostedService.StopAsync(CancellationToken.None)).ConfigureAwait(false);
|
|
|
|
Assert.That(fixture.ExecuteTask.IsCanceled, Is.True);
|
|
Assert.That(fixture.Transport.Server, Is.Null);
|
|
}
|
|
|
|
[Test]
|
|
public async Task UsernameInstanceAndCertificateFactoryForwardIdentityThroughLiveRegistryAsync()
|
|
{
|
|
byte[] password = Encoding.UTF8.GetBytes(Guid.NewGuid().ToString("N"));
|
|
var userHandler = new UserNameIdentityTokenHandler("composition-user", password);
|
|
var userIdentity = new UserIdentity(userHandler);
|
|
using Certificate certificate = CertificateBuilder.Create("CN=Composition User")
|
|
.SetRSAKeySize(CertificateFactory.DefaultKeySize).CreateForRSA();
|
|
var certificateHandler = new X509IdentityTokenHandler(new X509IdentityToken
|
|
{
|
|
PolicyId = "certificate",
|
|
CertificateData = certificate.RawData.ToByteString()
|
|
});
|
|
var certificateIdentity = new UserIdentity(certificateHandler);
|
|
using var cancellation = new CancellationTokenSource();
|
|
int userCalls = 0;
|
|
int certificateCalls = 0;
|
|
int factoryCalls = 0;
|
|
ICertificateValidatorEx observedValidator = null!;
|
|
var instance = new UserNamePasswordAuthenticator((handler, ct) =>
|
|
{
|
|
Assert.That(handler, Is.SameAs(userHandler));
|
|
Assert.That(handler.DecryptedPassword, Is.EqualTo(password));
|
|
Assert.That(ct, Is.EqualTo(cancellation.Token));
|
|
userCalls++;
|
|
return new ValueTask<IUserIdentity>(userIdentity);
|
|
});
|
|
var dependency = new CompositionObservations();
|
|
var fixture = HostedFixture.Create(builder =>
|
|
{
|
|
builder.Services.AddSingleton(dependency);
|
|
builder.AddIdentityAuthenticator(instance);
|
|
builder.AddIdentityAuthenticator((services, validator) =>
|
|
{
|
|
Assert.That(services.GetRequiredService<CompositionObservations>(), Is.SameAs(dependency));
|
|
observedValidator = validator!;
|
|
factoryCalls++;
|
|
return new X509Authenticator((handler, ct) =>
|
|
{
|
|
Assert.That(handler, Is.SameAs(certificateHandler));
|
|
Assert.That(ct, Is.EqualTo(cancellation.Token));
|
|
certificateCalls++;
|
|
return new ValueTask<IUserIdentity>(certificateIdentity);
|
|
});
|
|
});
|
|
});
|
|
await using ConfiguredAsyncDisposable cleanup = fixture.ConfigureAwait(false);
|
|
|
|
Assert.That(factoryCalls, Is.Zero);
|
|
await fixture.StartAsync().ConfigureAwait(false);
|
|
AuthenticationResult username = await fixture.Server.CurrentInstance.IdentityRegistry.AuthenticateAsync(
|
|
CreateAuthenticationContext(fixture.Context, userHandler), cancellation.Token).ConfigureAwait(false);
|
|
AuthenticationResult x509 = await fixture.Server.CurrentInstance.IdentityRegistry.AuthenticateAsync(
|
|
CreateAuthenticationContext(fixture.Context, certificateHandler), cancellation.Token)
|
|
.ConfigureAwait(false);
|
|
|
|
Assert.That(username.Outcome, Is.EqualTo(AuthenticationOutcome.Accepted));
|
|
Assert.That(username.Identity, Is.SameAs(userIdentity));
|
|
Assert.That(username.Error, Is.Null);
|
|
Assert.That(x509.Outcome, Is.EqualTo(AuthenticationOutcome.Accepted));
|
|
Assert.That(x509.Identity, Is.SameAs(certificateIdentity));
|
|
Assert.That(x509.Error, Is.Null);
|
|
Assert.That(observedValidator, Is.SameAs(fixture.Configuration.CertificateManager));
|
|
Assert.That(factoryCalls, Is.EqualTo(1));
|
|
Assert.That(userCalls, Is.EqualTo(1));
|
|
Assert.That(certificateCalls, Is.EqualTo(1));
|
|
Assert.That(fixture.Configuration.ServerConfiguration!.UserTokenPolicies
|
|
.ToArray()!.Select(policy => policy.TokenType), Is.EqualTo(s_anonymousTokens),
|
|
"Registering authentication must not implicitly advertise username or certificate policies.");
|
|
}
|
|
|
|
[Test]
|
|
public async Task CustomAnonymousAuthenticatorOverridesHostedDefaultAsync()
|
|
{
|
|
var rejection = new ServiceResult(
|
|
StatusCodes.BadUserAccessDenied, new LocalizedText("Anonymous access is disabled."));
|
|
var authenticator = new Mock<IUserTokenAuthenticator>();
|
|
authenticator.Setup(value => value.TokenType).Returns(UserTokenType.Anonymous);
|
|
authenticator.Setup(value => value.AuthenticateAsync(
|
|
It.IsAny<AuthenticationContext>(), It.IsAny<CancellationToken>()))
|
|
.Returns(new ValueTask<AuthenticationResult>(AuthenticationResult.Reject(rejection)));
|
|
var fixture = HostedFixture.Create(builder =>
|
|
builder.AddIdentityAuthenticator(authenticator.Object));
|
|
await using ConfiguredAsyncDisposable cleanup = fixture.ConfigureAwait(false);
|
|
await fixture.StartAsync().ConfigureAwait(false);
|
|
|
|
AuthenticationResult result = await fixture.Server.CurrentInstance.IdentityRegistry.AuthenticateAsync(
|
|
CreateAuthenticationContext(fixture.Context, new AnonymousIdentityTokenHandler()))
|
|
.ConfigureAwait(false);
|
|
|
|
Assert.That(result.Outcome, Is.EqualTo(AuthenticationOutcome.Rejected));
|
|
Assert.That(result.Error, Is.SameAs(rejection));
|
|
}
|
|
|
|
[Test]
|
|
public async Task ResourceCallbacksRunAfterDefaultsInOrderOnTheLiveResourceManagerAsync()
|
|
{
|
|
var seen = new List<ResourceManager>();
|
|
var observations = new CompositionObservations();
|
|
var fixture = HostedFixture.Create(builder =>
|
|
{
|
|
builder.Services.AddSingleton(observations);
|
|
builder.ConfigureResources(resources =>
|
|
{
|
|
seen.Add(resources);
|
|
ServiceResult builtIn = resources.Translate(["en-US"],
|
|
new ServiceResult(StatusCodes.BadNodeIdUnknown));
|
|
Assert.That(builtIn.LocalizedText.Text, Is.EqualTo("BadNodeIdUnknown"));
|
|
resources.Add("Composition.Greeting", "de-DE", "Erste Übersetzung");
|
|
observations.Events.Enqueue("first");
|
|
});
|
|
builder.ConfigureResources((services, resources) =>
|
|
{
|
|
Assert.That(services.GetRequiredService<CompositionObservations>(), Is.SameAs(observations));
|
|
Assert.That(resources.Translate(["de-DE"], "Composition.Greeting", "fallback").Text,
|
|
Is.EqualTo("Erste Übersetzung"));
|
|
seen.Add(resources);
|
|
resources.Add("Composition.Greeting", "de-DE", "Guten Tag");
|
|
resources.Add("Composition.Greeting", "fr-FR", "Bonjour");
|
|
resources.Add(StatusCodes.BadNodeIdUnknown, "de-DE", "Unbekannter Knoten");
|
|
observations.Events.Enqueue("second");
|
|
});
|
|
});
|
|
await using ConfiguredAsyncDisposable cleanup = fixture.ConfigureAwait(false);
|
|
await fixture.StartAsync().ConfigureAwait(false);
|
|
|
|
ResourceManager resources = fixture.Server.CurrentInstance.ResourceManager;
|
|
Assert.That(seen, Has.Count.EqualTo(2));
|
|
Assert.That(seen[0], Is.SameAs(resources));
|
|
Assert.That(seen[1], Is.SameAs(resources));
|
|
Assert.That(observations.Events, Is.EqualTo(s_resourceEvents));
|
|
LocalizedText german = resources.Translate(["de-DE"], "Composition.Greeting", "fallback");
|
|
Assert.That(german.Text, Is.EqualTo("Guten Tag"));
|
|
Assert.That(german.Locale, Is.EqualTo("de-DE"));
|
|
Assert.That(resources.Translate(["fr-FR"], "Composition.Greeting", "fallback").Text,
|
|
Is.EqualTo("Bonjour"));
|
|
ServiceResult translated = resources.Translate(["de-DE"], new ServiceResult(StatusCodes.BadNodeIdUnknown));
|
|
Assert.That(translated.LocalizedText.Text, Is.EqualTo("Unbekannter Knoten"));
|
|
Assert.That(translated.StatusCode.Code, Is.EqualTo((uint)StatusCodes.BadNodeIdUnknown));
|
|
Assert.That(resources.Translate(["en-US"], new ServiceResult(StatusCodes.BadNodeIdUnknown))
|
|
.LocalizedText.Text, Is.EqualTo("BadNodeIdUnknown"));
|
|
}
|
|
|
|
[Test]
|
|
public void ResourceManagersAreNotSharedByServersCreatedFromOneProvider()
|
|
{
|
|
int calls = 0;
|
|
IOpcUaServerBuilder builder = CreateBuilder().ConfigureResources(resources =>
|
|
{
|
|
calls++;
|
|
resources.Add("Composition.Shared", "en-US", "Configured");
|
|
});
|
|
using ServiceProvider provider = builder.Services.BuildServiceProvider();
|
|
using var first = new ResourceHookServer(provider);
|
|
using var second = new ResourceHookServer(provider);
|
|
var configuration = new ApplicationConfiguration(NUnitTelemetryContext.Create());
|
|
using ResourceManager firstResources = first.CreateResources(configuration);
|
|
using ResourceManager secondResources = second.CreateResources(configuration);
|
|
|
|
firstResources.Add("Composition.Shared", "en-US", "Only first");
|
|
|
|
Assert.That(calls, Is.EqualTo(2));
|
|
Assert.That(firstResources, Is.Not.SameAs(secondResources));
|
|
Assert.That(secondResources.Translate(["en-US"], "Composition.Shared", "fallback").Text,
|
|
Is.EqualTo("Configured"));
|
|
Assert.That(firstResources.Translate(["en-US"], "Composition.Shared", "fallback").Text,
|
|
Is.EqualTo("Only first"));
|
|
Assert.That(provider.GetService<ResourceManager>(), Is.Null);
|
|
}
|
|
|
|
[Test]
|
|
public async Task ResourceCallbackFailurePreventsStartupTasksAndClosesTransportAsync()
|
|
{
|
|
int starts = 0;
|
|
int resourceCalls = 0;
|
|
var fixture = HostedFixture.Create(builder => builder
|
|
.ConfigureResources(_ =>
|
|
{
|
|
resourceCalls++;
|
|
throw new InvalidOperationException("resource-composition-failure");
|
|
})
|
|
.AddStartupTask((_, _, _) =>
|
|
{
|
|
starts++;
|
|
return default;
|
|
}));
|
|
await using ConfiguredAsyncDisposable cleanup = fixture.ConfigureAwait(false);
|
|
|
|
Exception failure = await fixture.StartAndCaptureFailureAsync().ConfigureAwait(false);
|
|
|
|
Assert.That(failure, Is.InstanceOf<ServiceResultException>());
|
|
Assert.That(((ServiceResultException)failure).StatusCode, Is.EqualTo(StatusCodes.BadInternalError));
|
|
Assert.That(resourceCalls, Is.EqualTo(1));
|
|
Assert.That(starts, Is.Zero);
|
|
fixture.Transport.Listener.Verify(listener => listener.CloseAsync(
|
|
It.IsAny<CancellationToken>()), Times.AtLeastOnce);
|
|
}
|
|
|
|
[TestCase("properties")]
|
|
[TestCase("resources")]
|
|
[TestCase("aliases")]
|
|
[TestCase("instance")]
|
|
[TestCase("post-configure")]
|
|
public void CustomStandardServerRejectsUnsupportedCompositionHooks(string hook)
|
|
{
|
|
var services = new ServiceCollection();
|
|
services.AddLogging();
|
|
IOpcUaServerBuilder builder = services.AddOpcUa().AddServer<StandardServer>(_ => { });
|
|
switch (hook)
|
|
{
|
|
case "properties":
|
|
builder.ConfigureServerProperties(_ => { });
|
|
break;
|
|
case "resources":
|
|
builder.ConfigureResources(_ => { });
|
|
break;
|
|
case "aliases":
|
|
builder.ConfigureAliasNames(options => options.MaterializeAliasNodes = true);
|
|
break;
|
|
case "instance":
|
|
services.AddSingleton(new ServerProperties());
|
|
break;
|
|
case "post-configure":
|
|
services.PostConfigure<ServerProperties>(properties => properties.ProductName = "Unsupported");
|
|
break;
|
|
}
|
|
using ServiceProvider provider = services.BuildServiceProvider();
|
|
|
|
Assert.That(
|
|
() => provider.GetRequiredService<IOpcUaServerFactory>()
|
|
.CreateServer(NUnitTelemetryContext.Create(isServer: true), TimeProvider.System),
|
|
Throws.InvalidOperationException.With.Message.Contains("custom OPC UA server")
|
|
.And.Message.Contains("ConfigureServerProperties")
|
|
.And.Message.Contains("ConfigureResources")
|
|
.And.Message.Contains("ConfigureAliasNames"));
|
|
}
|
|
|
|
[Test]
|
|
public async Task RawStandardServerStillSupportsFactoriesIdentityAndStartupTasksAsync()
|
|
{
|
|
using var factory = new MarkerNodeManagerFactory("urn:composition:legacy-server", 91);
|
|
var observations = new CompositionObservations();
|
|
var fixture = new HostedFixture((services, root) =>
|
|
{
|
|
services.AddSingleton(observations);
|
|
services.AddOptions<ServerProperties>();
|
|
services.AddOptions<AliasNameServerOptions>();
|
|
return services.AddOpcUa()
|
|
.AddServer<StandardServer>(options => ConfigureOptions(options, root))
|
|
.AddNodeManager(factory)
|
|
.AddIdentityAuthenticator(new AnonymousAuthenticator())
|
|
.AddStartupTask<CompositionStartupTask>();
|
|
});
|
|
await using ConfiguredAsyncDisposable cleanup = fixture.ConfigureAwait(false);
|
|
|
|
await fixture.StartAsync().ConfigureAwait(false);
|
|
AuthenticationResult identity = await fixture.Server.CurrentInstance.IdentityRegistry.AuthenticateAsync(
|
|
CreateAuthenticationContext(fixture.Context, new AnonymousIdentityTokenHandler()),
|
|
CancellationToken.None).ConfigureAwait(false);
|
|
|
|
Assert.That(fixture.Server, Is.TypeOf<StandardServer>());
|
|
AssertMarker(fixture.Context, factory.NamespacesUris[0], 91);
|
|
Assert.That(observations.StartupCount, Is.EqualTo(1));
|
|
Assert.That(identity.Outcome, Is.EqualTo(AuthenticationOutcome.Accepted));
|
|
Assert.That(identity.Identity!.TokenType, Is.EqualTo(UserTokenType.Anonymous));
|
|
}
|
|
|
|
[TestCase(false, false, false)]
|
|
[TestCase(true, false, false)]
|
|
[TestCase(false, true, false)]
|
|
[TestCase(true, true, false)]
|
|
[TestCase(false, false, true)]
|
|
[TestCase(false, true, true)]
|
|
public async Task PreStartupInitializedAliasesAreRegisteredBeforeAddressSpaceAsync(
|
|
bool materialize,
|
|
bool topics,
|
|
bool plainServer)
|
|
{
|
|
NodeId categoryId = topics ? ObjectIds.Topics : ObjectIds.TagVariables;
|
|
using var store = new InMemoryAliasNameStore(
|
|
[new AliasNameCategoryDescriptor(
|
|
categoryId,
|
|
QualifiedName.From(topics ? BrowseNames.Topics : BrowseNames.TagVariables),
|
|
AliasNameCapabilities.All)]);
|
|
using var registry = new AliasNameStoreRegistry();
|
|
TaskCompletionSource<bool> entered = NewSignal<bool>();
|
|
TaskCompletionSource<bool> release = NewSignal<bool>();
|
|
int initializerResolutions = 0;
|
|
var initializer = new Mock<IServerPreStartupTask>(MockBehavior.Strict);
|
|
initializer.Setup(task => task.OnServerStartingAsync(
|
|
It.IsAny<IServerContext>(), It.IsAny<CancellationToken>()))
|
|
.Returns((IServerContext _, CancellationToken ct) => InitializeAsync(ct));
|
|
var followingTask = new Mock<IServerPreStartupTask>(MockBehavior.Strict);
|
|
followingTask.Setup(task => task.OnServerStartingAsync(
|
|
It.IsAny<IServerContext>(), It.IsAny<CancellationToken>()))
|
|
.Returns(() =>
|
|
{
|
|
Assert.That(registry.Stores, Does.Contain(store));
|
|
return default;
|
|
});
|
|
var fixture = new HostedFixture((services, root) =>
|
|
{
|
|
services.AddTransient(_ =>
|
|
{
|
|
initializerResolutions++;
|
|
return initializer.Object;
|
|
});
|
|
services.AddSingleton(followingTask.Object);
|
|
IOpcUaBuilder builder = services.AddOpcUa();
|
|
IOpcUaServerBuilder server = plainServer
|
|
? builder.AddServer<StandardServer>(options => ConfigureOptions(options, root))
|
|
: builder.AddServer(options => ConfigureOptions(options, root));
|
|
server.AddAliasNameStoreRegistry(registry);
|
|
if (materialize)
|
|
{
|
|
server.ConfigureAliasNames(options => options.MaterializeAliasNodes = true);
|
|
}
|
|
return server;
|
|
});
|
|
await using ConfiguredAsyncDisposable cleanup = fixture.ConfigureAwait(false);
|
|
|
|
Task starting = fixture.StartAsync();
|
|
try
|
|
{
|
|
await AwaitBoundedAsync(entered.Task).ConfigureAwait(false);
|
|
Assert.That(starting.IsCompleted, Is.False);
|
|
Assert.That(registry.Stores, Is.Empty);
|
|
}
|
|
finally
|
|
{
|
|
release.TrySetResult(true);
|
|
}
|
|
await AwaitBoundedAsync(starting).ConfigureAwait(false);
|
|
|
|
Assert.That(initializerResolutions, Is.EqualTo(1));
|
|
initializer.Verify(task => task.OnServerStartingAsync(
|
|
It.IsAny<IServerContext>(), It.IsAny<CancellationToken>()), Times.Once);
|
|
followingTask.Verify(task => task.OnServerStartingAsync(
|
|
It.IsAny<IServerContext>(), It.IsAny<CancellationToken>()), Times.Once);
|
|
AliasNameCategoryState category = fixture.Context
|
|
.FindPredefinedNode<AliasNameCategoryState>(categoryId)!;
|
|
ArrayOf<AliasNameDataType> aliases = await FindAliasesAsync(fixture.Context, category!)
|
|
.ConfigureAwait(false);
|
|
Assert.That(aliases.Count, Is.EqualTo(1));
|
|
Assert.That(aliases[0].AliasName.Name, Is.EqualTo("InitializedAlias"));
|
|
Assert.That(aliases[0].ReferencedNodes[0],
|
|
Is.EqualTo((ExpandedNodeId)VariableIds.Server_ServerStatus_BuildInfo_ProductName));
|
|
|
|
var references = new List<IReference>();
|
|
category.GetReferences(fixture.Context.DefaultSystemContext, references);
|
|
IReference[] browseAliases = [.. references.Where(reference =>
|
|
reference.ReferenceTypeId == ReferenceTypeIds.Organizes && !reference.IsInverse)];
|
|
Assert.That(browseAliases, Has.Length.EqualTo(materialize ? 1 : 0));
|
|
if (materialize)
|
|
{
|
|
var aliasId = ExpandedNodeId.ToNodeId(
|
|
browseAliases[0].TargetId, fixture.Context.DefaultSystemContext.NamespaceUris);
|
|
AliasNameState alias = fixture.Context.FindPredefinedNode<AliasNameState>(aliasId)!;
|
|
Assert.That(alias!.BrowseName.Name, Is.EqualTo("InitializedAlias"));
|
|
Assert.That(alias.ReferenceExists(ReferenceTypeIds.AliasFor, false,
|
|
VariableIds.Server_ServerStatus_BuildInfo_ProductName), Is.True);
|
|
Assert.That(category.FindAliasVerbose, Is.Not.Null);
|
|
}
|
|
|
|
async ValueTask InitializeAsync(CancellationToken ct)
|
|
{
|
|
Assert.That(ct.CanBeCanceled, Is.True);
|
|
entered.TrySetResult(true);
|
|
await release.Task.ConfigureAwait(false);
|
|
ct.ThrowIfCancellationRequested();
|
|
store.Seed(categoryId, "InitializedAlias",
|
|
VariableIds.Server_ServerStatus_BuildInfo_ProductName, null, ReferenceTypeIds.AliasFor);
|
|
registry.Register(store);
|
|
}
|
|
}
|
|
|
|
[TestCase(false)]
|
|
[TestCase(true)]
|
|
public async Task FailedPreStartupInitializationDoesNotCopyAliasStoresAsync(bool cancel)
|
|
{
|
|
using var store = new InMemoryAliasNameStore(
|
|
[new AliasNameCategoryDescriptor(
|
|
ObjectIds.TagVariables,
|
|
QualifiedName.From(BrowseNames.TagVariables),
|
|
AliasNameCapabilities.All)]);
|
|
var registry = new Mock<IAliasNameStoreRegistry>(MockBehavior.Strict);
|
|
registry.SetupGet(source => source.Stores).Returns([store]);
|
|
TaskCompletionSource<bool> entered = NewSignal<bool>();
|
|
var initializer = new Mock<IServerPreStartupTask>(MockBehavior.Strict);
|
|
initializer.Setup(task => task.OnServerStartingAsync(
|
|
It.IsAny<IServerContext>(), It.IsAny<CancellationToken>()))
|
|
.Returns((IServerContext _, CancellationToken ct) => InitializeAsync(ct));
|
|
var followingTask = new Mock<IServerPreStartupTask>(MockBehavior.Strict);
|
|
var fixture = HostedFixture.Create(builder =>
|
|
{
|
|
builder.Services.AddSingleton(initializer.Object);
|
|
builder.Services.AddSingleton(followingTask.Object);
|
|
builder.AddAliasNameStoreRegistry(registry.Object);
|
|
builder.ConfigureAliasNames(options => options.MaterializeAliasNodes = true);
|
|
});
|
|
await using ConfiguredAsyncDisposable cleanup = fixture.ConfigureAwait(false);
|
|
|
|
Task<Exception> failure = fixture.StartAndCaptureFailureAsync();
|
|
await AwaitBoundedAsync(entered.Task).ConfigureAwait(false);
|
|
if (cancel)
|
|
{
|
|
await fixture.StopAsync().ConfigureAwait(false);
|
|
}
|
|
await AwaitBoundedAsync(failure).ConfigureAwait(false);
|
|
Exception actual = await failure.ConfigureAwait(false);
|
|
|
|
Assert.That(fixture.ExecuteTask.IsCanceled, Is.EqualTo(cancel));
|
|
Assert.That(fixture.ExecuteTask.IsFaulted, Is.EqualTo(!cancel));
|
|
if (cancel)
|
|
{
|
|
Assert.That(actual, Is.InstanceOf<OperationCanceledException>());
|
|
}
|
|
else
|
|
{
|
|
Assert.That(actual, Is.InstanceOf<ServiceResultException>());
|
|
}
|
|
initializer.Verify(task => task.OnServerStartingAsync(
|
|
It.IsAny<IServerContext>(), It.IsAny<CancellationToken>()), Times.Once);
|
|
followingTask.Verify(task => task.OnServerStartingAsync(
|
|
It.IsAny<IServerContext>(), It.IsAny<CancellationToken>()), Times.Never);
|
|
registry.VerifyGet(source => source.Stores, Times.Never);
|
|
|
|
async ValueTask InitializeAsync(CancellationToken ct)
|
|
{
|
|
entered.TrySetResult(true);
|
|
if (cancel)
|
|
{
|
|
await Task.Delay(Timeout.Infinite, ct).ConfigureAwait(false);
|
|
}
|
|
throw new InvalidOperationException("Alias initialization failed.");
|
|
}
|
|
}
|
|
|
|
[TestCase(false, false)]
|
|
[TestCase(true, false)]
|
|
[TestCase(false, true)]
|
|
[TestCase(true, true)]
|
|
public async Task StandardAliasMaterializationIsOptInWhileQueriesRemainLiveAsync(bool materialize, bool topics)
|
|
{
|
|
NodeId categoryId = topics ? ObjectIds.Topics : ObjectIds.TagVariables;
|
|
var descriptor = new AliasNameCategoryDescriptor(
|
|
categoryId,
|
|
QualifiedName.From(topics ? BrowseNames.Topics : BrowseNames.TagVariables),
|
|
AliasNameCapabilities.All);
|
|
using var store = new InMemoryAliasNameStore([descriptor]);
|
|
using var registry = new AliasNameStoreRegistry();
|
|
store.Seed(categoryId, "BuildName",
|
|
VariableIds.Server_ServerStatus_BuildInfo_ProductName, null, ReferenceTypeIds.AliasFor);
|
|
var fixture = HostedFixture.Create(builder =>
|
|
{
|
|
builder.AddAliasNameStore(store);
|
|
if (materialize)
|
|
{
|
|
builder.ConfigureAliasNames(options => options.MaterializeAliasNodes = true);
|
|
registry.Register(store);
|
|
builder.AddAliasNameStoreRegistry(registry);
|
|
}
|
|
});
|
|
await using ConfiguredAsyncDisposable cleanup = fixture.ConfigureAwait(false);
|
|
await fixture.StartAsync().ConfigureAwait(false);
|
|
|
|
AliasNameCategoryState category = fixture.Context
|
|
.FindPredefinedNode<AliasNameCategoryState>(categoryId)!;
|
|
Assert.That(category, Is.Not.Null);
|
|
ArrayOf<AliasNameDataType> initial = await FindAliasesAsync(fixture.Context, category)
|
|
.ConfigureAwait(false);
|
|
Assert.That(initial.Count, Is.EqualTo(1));
|
|
Assert.That(initial[0].AliasName.Name, Is.EqualTo("BuildName"));
|
|
Assert.That(initial[0].ReferencedNodes[0],
|
|
Is.EqualTo((ExpandedNodeId)VariableIds.Server_ServerStatus_BuildInfo_ProductName));
|
|
|
|
NodeId aliasId = default;
|
|
var references = new List<IReference>();
|
|
category.GetReferences(fixture.Context.DefaultSystemContext, references);
|
|
AliasNameState[] aliases = [.. references
|
|
.Where(reference => reference.ReferenceTypeId == ReferenceTypeIds.Organizes && !reference.IsInverse)
|
|
.Select(reference => fixture.Context.FindPredefinedNode<AliasNameState>(
|
|
ExpandedNodeId.ToNodeId(reference.TargetId, fixture.Context.DefaultSystemContext.NamespaceUris)))
|
|
.Where(node => node != null)!];
|
|
Assert.That(aliases, Has.Length.EqualTo(materialize ? 1 : 0));
|
|
if (materialize)
|
|
{
|
|
AliasNameState alias = aliases.Single();
|
|
aliasId = alias.NodeId;
|
|
Assert.That(alias.BrowseName.Name, Is.EqualTo("BuildName"));
|
|
Assert.That(alias.BrowseName.NamespaceIndex, Is.Not.Zero);
|
|
Assert.That(alias.TypeDefinitionId, Is.EqualTo(ObjectTypeIds.AliasNameType));
|
|
Assert.That(alias.ReferenceExists(ReferenceTypeIds.AliasFor, false,
|
|
VariableIds.Server_ServerStatus_BuildInfo_ProductName), Is.True);
|
|
BaseVariableState target = fixture.Context.FindPredefinedNode<BaseVariableState>(
|
|
VariableIds.Server_ServerStatus_BuildInfo_ProductName)!;
|
|
Assert.That(target!.ReferenceExists(ReferenceTypeIds.AliasFor, true, aliasId), Is.True);
|
|
Assert.That(category.FindAliasVerbose, Is.Not.Null);
|
|
Assert.That(category.AddAliasesToCategory, Is.Not.Null);
|
|
Assert.That(category.DeleteAliasesFromCategory, Is.Not.Null);
|
|
Assert.That(category.LastChange!.Value, Is.Zero);
|
|
}
|
|
else
|
|
{
|
|
Assert.That(category.FindAliasVerbose, Is.Null);
|
|
Assert.That(category.AddAliasesToCategory, Is.Null);
|
|
Assert.That(category.DeleteAliasesFromCategory, Is.Null);
|
|
Assert.That(fixture.Services.GetRequiredService<IOptions<AliasNameServerOptions>>()
|
|
.Value.MaterializeAliasNodes, Is.False);
|
|
}
|
|
|
|
NodeId addedAliasId = default;
|
|
TaskCompletionSource<bool> refreshed = NewSignal<bool>();
|
|
if (materialize)
|
|
{
|
|
addedAliasId = new NodeId(
|
|
Utils.Format("{0}.{1}", categoryId, "BuildNumber"), aliasId.NamespaceIndex);
|
|
category.OnStateChanged += (_, _, _) =>
|
|
{
|
|
if (fixture.Context.FindPredefinedNode<AliasNameState>(addedAliasId) != null &&
|
|
category.ReferenceExists(ReferenceTypeIds.Organizes, false, addedAliasId))
|
|
{
|
|
refreshed.TrySetResult(true);
|
|
}
|
|
};
|
|
}
|
|
|
|
StatusCode[] changes = await store.AddAliasesAsync(categoryId,
|
|
[new AliasAddRequest("BuildNumber", VariableIds.Server_ServerStatus_BuildInfo_BuildNumber,
|
|
null, ReferenceTypeIds.AliasFor)], CancellationToken.None).ConfigureAwait(false);
|
|
if (materialize)
|
|
{
|
|
await refreshed.Task.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false);
|
|
}
|
|
ArrayOf<AliasNameDataType> current = await FindAliasesAsync(fixture.Context, category)
|
|
.ConfigureAwait(false);
|
|
Assert.That(changes, Has.Length.EqualTo(1));
|
|
Assert.That(changes[0].Code, Is.EqualTo((uint)StatusCodes.Good));
|
|
Assert.That(current.ToArray()!.Select(alias => alias.AliasName.Name),
|
|
Is.EquivalentTo(s_mutatedAliases));
|
|
references.Clear();
|
|
category.GetReferences(fixture.Context.DefaultSystemContext, references);
|
|
Assert.That(references.Count(reference =>
|
|
reference.ReferenceTypeId == ReferenceTypeIds.Organizes && !reference.IsInverse),
|
|
Is.EqualTo(materialize ? 2 : 0),
|
|
"Materialized aliases must follow store changes by default.");
|
|
if (materialize)
|
|
{
|
|
Assert.That(category.LastChange!.Value, Is.EqualTo(1u));
|
|
Assert.That(fixture.Context.FindPredefinedNode<AliasNameState>(aliasId), Is.SameAs(aliases[0]));
|
|
AliasNameState addedAlias = fixture.Context.FindPredefinedNode<AliasNameState>(addedAliasId)!;
|
|
Assert.That(addedAlias, Is.Not.Null);
|
|
Assert.That(addedAlias.BrowseName.Name, Is.EqualTo("BuildNumber"));
|
|
Assert.That(addedAlias.TypeDefinitionId, Is.EqualTo(ObjectTypeIds.AliasNameType));
|
|
Assert.That(addedAlias.ReferenceExists(
|
|
ReferenceTypeIds.AliasFor, false, VariableIds.Server_ServerStatus_BuildInfo_BuildNumber), Is.True);
|
|
BaseVariableState target = fixture.Context.FindPredefinedNode<BaseVariableState>(
|
|
VariableIds.Server_ServerStatus_BuildInfo_BuildNumber)!;
|
|
Assert.That(target!.ReferenceExists(ReferenceTypeIds.AliasFor, true, addedAliasId), Is.True);
|
|
}
|
|
}
|
|
|
|
[TestCase(false, false)]
|
|
[TestCase(true, false)]
|
|
[TestCase(false, true)]
|
|
[TestCase(true, true)]
|
|
public async Task StandardLiveAliasRefreshRequiresBothOptInsAsync(bool materialize, bool topics)
|
|
{
|
|
NodeId categoryId = topics ? ObjectIds.Topics : ObjectIds.TagVariables;
|
|
using var store = new InMemoryAliasNameStore(
|
|
[new AliasNameCategoryDescriptor(categoryId,
|
|
QualifiedName.From(topics ? BrowseNames.Topics : BrowseNames.TagVariables),
|
|
AliasNameCapabilities.All)]);
|
|
store.Seed(categoryId, "BuildName",
|
|
VariableIds.Server_ServerStatus_BuildInfo_ProductName, null, ReferenceTypeIds.AliasFor);
|
|
var fixture = HostedFixture.Create(builder =>
|
|
{
|
|
builder.AddAliasNameStore(store);
|
|
builder.ConfigureAliasNames(options =>
|
|
{
|
|
options.MaterializeAliasNodes = materialize;
|
|
options.RefreshAliasNodesOnChange = true;
|
|
});
|
|
});
|
|
await using ConfiguredAsyncDisposable cleanup = fixture.ConfigureAwait(false);
|
|
await fixture.StartAsync().ConfigureAwait(false);
|
|
AliasNameCategoryState category = fixture.Context.FindPredefinedNode<AliasNameCategoryState>(categoryId)!;
|
|
ushort ns = fixture.Context.DefaultSystemContext.NamespaceUris.GetIndexOrAppend(
|
|
Ua.Namespaces.OpcUa + "Diagnostics");
|
|
var originalId = new NodeId(Utils.Format("{0}.{1}", categoryId, "BuildName"), ns);
|
|
var addedId = new NodeId(Utils.Format("{0}.{1}", categoryId, "BuildNumber"), ns);
|
|
AliasNameState original = fixture.Context.FindPredefinedNode<AliasNameState>(originalId)!;
|
|
var published = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
if (materialize)
|
|
{
|
|
Assert.That(original, Is.Not.Null);
|
|
category!.OnStateChanged += (_, _, _) =>
|
|
{
|
|
if (fixture.Context.FindPredefinedNode<AliasNameState>(addedId) != null)
|
|
{
|
|
published.TrySetResult(true);
|
|
}
|
|
};
|
|
}
|
|
|
|
await store.AddAliasesAsync(categoryId,
|
|
[new AliasAddRequest("BuildNumber", VariableIds.Server_ServerStatus_BuildInfo_BuildNumber,
|
|
null, ReferenceTypeIds.AliasFor)]).ConfigureAwait(false);
|
|
if (materialize)
|
|
{
|
|
await published.Task.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false);
|
|
Assert.That(fixture.Context.FindPredefinedNode<AliasNameState>(originalId), Is.SameAs(original));
|
|
BaseVariableState target = fixture.Context.FindPredefinedNode<BaseVariableState>(
|
|
VariableIds.Server_ServerStatus_BuildInfo_BuildNumber)!;
|
|
Assert.That(target!.ReferenceExists(ReferenceTypeIds.AliasFor, true, addedId), Is.True);
|
|
}
|
|
else
|
|
{
|
|
Assert.That(fixture.Context.FindPredefinedNode<AliasNameState>(addedId), Is.Null);
|
|
Assert.That(category!.FindAliasVerbose, Is.Null);
|
|
Assert.That(category.AddAliasesToCategory, Is.Null);
|
|
Assert.That(category.DeleteAliasesFromCategory, Is.Null);
|
|
}
|
|
ArrayOf<AliasNameDataType> current = await FindAliasesAsync(fixture.Context, category!)
|
|
.ConfigureAwait(false);
|
|
Assert.That(current.ToArray()!.Select(alias => alias.AliasName.Name), Is.EquivalentTo(s_mutatedAliases));
|
|
}
|
|
|
|
[Test]
|
|
public async Task DefaultHostedReverseConnectUsesInjectedSessionHookAndStopsItsTimerAsync()
|
|
{
|
|
var clock = new FakeTimeProvider();
|
|
var enabled = new Uri("opc.tcp://localhost:4841/enabled");
|
|
var disabled = new Uri("opc.tcp://localhost:4842/disabled");
|
|
SessionManager sessionManager = null!;
|
|
var fixture = HostedFixture.Create(
|
|
builder =>
|
|
{
|
|
builder.Services.AddSingleton<TimeProvider>(clock);
|
|
builder.AddSessionManager((_, server, configuration) =>
|
|
{
|
|
sessionManager = new SessionManager(server, configuration, clock);
|
|
return sessionManager;
|
|
});
|
|
},
|
|
options =>
|
|
{
|
|
options.ReverseConnect = new ServerReverseConnectOptions { ConnectIntervalMs = 2000 };
|
|
options.ReverseConnect.Clients.Add(new ServerReverseConnectClientOptions
|
|
{
|
|
EndpointUrl = enabled.ToString(),
|
|
Timeout = 3210,
|
|
MaxSessionCount = 0
|
|
});
|
|
options.ReverseConnect.Clients.Add(new ServerReverseConnectClientOptions
|
|
{
|
|
EndpointUrl = disabled.ToString(),
|
|
Enabled = false
|
|
});
|
|
});
|
|
await using ConfiguredAsyncDisposable cleanup = fixture.ConfigureAwait(false);
|
|
await fixture.StartAsync().ConfigureAwait(false);
|
|
|
|
Assert.That(fixture.Server, Is.InstanceOf<ReverseConnectServer>());
|
|
Assert.That(fixture.Server.CurrentInstance.SessionManager, Is.SameAs(sessionManager));
|
|
fixture.Transport.Listener.Verify(listener => listener.CreateReverseConnection(
|
|
It.IsAny<Uri>(), It.IsAny<int>()), Times.Never);
|
|
clock.Advance(TimeSpan.FromSeconds(2));
|
|
fixture.Transport.Listener.Verify(listener => listener.CreateReverseConnection(enabled, 3210), Times.Once);
|
|
fixture.Transport.Listener.Verify(listener => listener.CreateReverseConnection(
|
|
disabled, It.IsAny<int>()), Times.Never);
|
|
var reverseServer = (ReverseConnectServer)fixture.Server;
|
|
Assert.That(reverseServer.GetReverseConnections()[enabled].LastState,
|
|
Is.EqualTo(ReverseConnectState.Connecting));
|
|
Assert.That(ReadBuildInfo(fixture.Context).ProductName, Is.EqualTo("CompositionServer"));
|
|
|
|
await fixture.StopAsync().ConfigureAwait(false);
|
|
clock.Advance(TimeSpan.FromSeconds(10));
|
|
fixture.Transport.Listener.Verify(listener => listener.CreateReverseConnection(
|
|
It.IsAny<Uri>(), It.IsAny<int>()), Times.Once);
|
|
Assert.That(reverseServer.GetReverseConnections()[enabled].LastState,
|
|
Is.EqualTo(ReverseConnectState.Connecting),
|
|
"A leaked timer would attempt another connection against the stopped server.");
|
|
fixture.Transport.Listener.Verify(listener => listener.CloseAsync(
|
|
It.IsAny<CancellationToken>()), Times.AtLeastOnce);
|
|
}
|
|
|
|
[Test]
|
|
public async Task DefaultHostedServerWithoutReverseClientsNeverDialsAsync()
|
|
{
|
|
var clock = new FakeTimeProvider();
|
|
var fixture = HostedFixture.Create(
|
|
builder => builder.Services.AddSingleton<TimeProvider>(clock));
|
|
await using ConfiguredAsyncDisposable cleanup = fixture.ConfigureAwait(false);
|
|
await fixture.StartAsync().ConfigureAwait(false);
|
|
|
|
clock.Advance(TimeSpan.FromMinutes(1));
|
|
|
|
Assert.That(fixture.Server, Is.InstanceOf<ReverseConnectServer>());
|
|
Assert.That(((ReverseConnectServer)fixture.Server).GetReverseConnections(), Is.Empty);
|
|
fixture.Transport.Listener.Verify(listener => listener.CreateReverseConnection(
|
|
It.IsAny<Uri>(), It.IsAny<int>()), Times.Never);
|
|
Assert.That(fixture.Context.CurrentState, Is.EqualTo(ServerState.Running));
|
|
}
|
|
|
|
private static IOpcUaServerBuilder CreateBuilder()
|
|
{
|
|
var services = new ServiceCollection();
|
|
services.AddLogging();
|
|
return services.AddOpcUa().AddServer(_ => { });
|
|
}
|
|
|
|
private static TaskCompletionSource<T> NewSignal<T>()
|
|
{
|
|
return new TaskCompletionSource<T>(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
}
|
|
|
|
private static async Task AwaitBoundedAsync(Task task)
|
|
{
|
|
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(60));
|
|
TaskCompletionSource<bool> expired = NewSignal<bool>();
|
|
using CancellationTokenRegistration registration =
|
|
timeout.Token.Register(() => expired.TrySetResult(true));
|
|
if (await Task.WhenAny(task, expired.Task).ConfigureAwait(false) != task)
|
|
{
|
|
throw new TimeoutException("The hosted composition operation did not complete.");
|
|
}
|
|
await task.ConfigureAwait(false);
|
|
}
|
|
|
|
private static BuildInfo ReadBuildInfo(IServerContext context)
|
|
{
|
|
DataValue value = ReadValue(context, VariableIds.Server_ServerStatus_BuildInfo);
|
|
Assert.That(value.WrappedValue.TryGetStructure<BuildInfo>(out BuildInfo? info), Is.True);
|
|
return info!;
|
|
}
|
|
|
|
private static DataValue ReadValue(IServerContext context, NodeId nodeId)
|
|
{
|
|
BaseVariableState node = context.FindPredefinedNode<BaseVariableState>(nodeId)!;
|
|
return ReadValue(context, node);
|
|
}
|
|
|
|
private static DataValue ReadValue(IServerContext context, BaseVariableState node)
|
|
{
|
|
Assert.That(node, Is.Not.Null);
|
|
var value = new DataValue();
|
|
ServiceResult result = node.ReadAttribute(context.DefaultSystemContext,
|
|
Attributes.Value, NumericRange.Null, QualifiedName.Null, ref value);
|
|
Assert.That(ServiceResult.IsGood(result), Is.True);
|
|
Assert.That(value.StatusCode.Code, Is.EqualTo((uint)StatusCodes.Good));
|
|
return value;
|
|
}
|
|
|
|
private static void AssertMarker(IServerContext context, string namespaceUri, int marker)
|
|
{
|
|
int namespaceIndex = context.DefaultSystemContext.NamespaceUris.GetIndex(namespaceUri);
|
|
Assert.That(namespaceIndex, Is.GreaterThan(0));
|
|
NodeId nodeId = new("Marker", checked((ushort)namespaceIndex));
|
|
BaseVariableState node = context.FindNodeManagers<MarkerNodeManager>()
|
|
.Select(manager => manager.FindPredefinedNode<BaseVariableState>(nodeId))
|
|
.Concat(context.FindNodeManagers<LegacyMarkerNodeManager>()
|
|
.Select(manager => manager.FindPredefinedNode<BaseVariableState>(nodeId)))
|
|
.Single(value => value != null)!;
|
|
DataValue value = ReadValue(context, node!);
|
|
Assert.That(value.WrappedValue.GetInt32(), Is.EqualTo(marker));
|
|
}
|
|
|
|
private static AuthenticationContext CreateAuthenticationContext(
|
|
IServerContext context,
|
|
IUserIdentityTokenHandler handler)
|
|
{
|
|
return new AuthenticationContext(
|
|
handler,
|
|
new UserTokenPolicy { TokenType = handler.TokenType },
|
|
new EndpointDescription { SecurityMode = MessageSecurityMode.SignAndEncrypt },
|
|
context.MessageContext);
|
|
}
|
|
|
|
private static async Task<ArrayOf<AliasNameDataType>> FindAliasesAsync(
|
|
IServerContext context,
|
|
AliasNameCategoryState category)
|
|
{
|
|
var argumentErrors = new List<ServiceResult>();
|
|
var output = new List<Variant>();
|
|
ServiceResult result = await category.FindAlias!.CallAsync(
|
|
context.DefaultSystemContext, category.NodeId,
|
|
[new Variant("%"), new Variant(NodeId.Null)],
|
|
argumentErrors, output, CancellationToken.None).ConfigureAwait(false);
|
|
Assert.That(ServiceResult.IsGood(result), Is.True);
|
|
Assert.That(argumentErrors.All(ServiceResult.IsGood), Is.True);
|
|
Assert.That(output, Has.Count.EqualTo(1));
|
|
Assert.That(output[0].TryGetStructure(out ArrayOf<AliasNameDataType> aliases), Is.True);
|
|
return aliases;
|
|
}
|
|
|
|
private static void ConfigureOptions(OpcUaServerOptions options, string root)
|
|
{
|
|
options.ApplicationName = "CompositionServer";
|
|
options.ApplicationUri = "urn:localhost:CompositionServer";
|
|
options.ProductUri = "urn:composition:product";
|
|
options.PkiRoot = root;
|
|
options.IncludeSignAndEncryptPolicies = false;
|
|
options.IncludeUnsecurePolicyNone = true;
|
|
options.AutoAcceptUntrustedCertificates = true;
|
|
options.EndpointUrls.Add("opc.tcp://localhost:0/CompositionServer");
|
|
options.ConfigureBuilder = builder => builder.SetShutdownDelay(0);
|
|
}
|
|
|
|
private static string CreateConfigurationXml(string root)
|
|
{
|
|
string pki = System.Security.SecurityElement.Escape(root.Replace('\\', '/'))!;
|
|
return $"""
|
|
<?xml version="1.0" encoding="utf-8"?>
|
|
<ApplicationConfiguration
|
|
xmlns:ua="http://opcfoundation.org/UA/2008/02/Types.xsd"
|
|
xmlns="http://opcfoundation.org/UA/SDK/Configuration.xsd">
|
|
<ApplicationName>XmlComposition</ApplicationName>
|
|
<ApplicationUri>urn:localhost:XmlComposition</ApplicationUri>
|
|
<ProductUri>urn:composition:xml-product</ProductUri>
|
|
<ApplicationType>Server_0</ApplicationType>
|
|
<SecurityConfiguration>
|
|
<ApplicationCertificates>
|
|
<CertificateIdentifier>
|
|
<StoreType>Directory</StoreType>
|
|
<StorePath>{pki}/own</StorePath>
|
|
<SubjectName>CN=XmlComposition, O=OPC Foundation, DC=localhost</SubjectName>
|
|
<CertificateTypeString>RsaSha256</CertificateTypeString>
|
|
</CertificateIdentifier>
|
|
</ApplicationCertificates>
|
|
<TrustedIssuerCertificates>
|
|
<StoreType>Directory</StoreType><StorePath>{pki}/issuer</StorePath>
|
|
</TrustedIssuerCertificates>
|
|
<TrustedPeerCertificates>
|
|
<StoreType>Directory</StoreType><StorePath>{pki}/trusted</StorePath>
|
|
</TrustedPeerCertificates>
|
|
<RejectedCertificateStore>
|
|
<StoreType>Directory</StoreType><StorePath>{pki}/rejected</StorePath>
|
|
</RejectedCertificateStore>
|
|
<AutoAcceptUntrustedCertificates>true</AutoAcceptUntrustedCertificates>
|
|
</SecurityConfiguration>
|
|
<TransportConfigurations></TransportConfigurations>
|
|
<TransportQuotas>
|
|
<OperationTimeout>90000</OperationTimeout>
|
|
<MaxStringLength>654321</MaxStringLength>
|
|
<MaxByteStringLength>1048576</MaxByteStringLength>
|
|
<MaxArrayLength>65535</MaxArrayLength>
|
|
<MaxMessageSize>4194304</MaxMessageSize>
|
|
<MaxBufferSize>65535</MaxBufferSize>
|
|
<ChannelLifetime>300000</ChannelLifetime>
|
|
<SecurityTokenLifetime>3600000</SecurityTokenLifetime>
|
|
</TransportQuotas>
|
|
<ServerConfiguration>
|
|
<BaseAddresses><ua:String>opc.tcp://localhost:0/XmlComposition</ua:String></BaseAddresses>
|
|
<SecurityPolicies>
|
|
<ServerSecurityPolicy>
|
|
<SecurityMode>None_1</SecurityMode>
|
|
<SecurityPolicyUri>http://opcfoundation.org/UA/SecurityPolicy#None</SecurityPolicyUri>
|
|
</ServerSecurityPolicy>
|
|
</SecurityPolicies>
|
|
<UserTokenPolicies>
|
|
<ua:UserTokenPolicy><ua:TokenType>Anonymous_0</ua:TokenType></ua:UserTokenPolicy>
|
|
</UserTokenPolicies>
|
|
<DiagnosticsEnabled>true</DiagnosticsEnabled>
|
|
<MaxSessionCount>77</MaxSessionCount>
|
|
<MinSessionTimeout>10000</MinSessionTimeout>
|
|
<MaxSessionTimeout>3600000</MaxSessionTimeout>
|
|
<ShutdownDelay>0</ShutdownDelay>
|
|
</ServerConfiguration>
|
|
</ApplicationConfiguration>
|
|
""";
|
|
}
|
|
|
|
public sealed class CompositionObservations
|
|
{
|
|
public ConcurrentQueue<string> Events { get; } = new();
|
|
public IServerContext Context { get; set; } = null!;
|
|
public NodeId HistoryNodeId { get; set; }
|
|
public int StartupCount { get; set; }
|
|
public int FactoryConstructions { get; set; }
|
|
public bool ConfigurationLoaded { get; set; }
|
|
public bool FactorySawLoadedConfiguration { get; set; }
|
|
public DelayedNodeManagerFactory Factory { get; set; } = null!;
|
|
}
|
|
|
|
public sealed class CompositionStartupTask(CompositionObservations observations) : IServerStartupTask
|
|
{
|
|
public async ValueTask OnServerStartedAsync(
|
|
IServerContext server,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
observations.StartupCount++;
|
|
observations.Context = server;
|
|
observations.Events.Enqueue("generic");
|
|
IDiagnosticsNodeManager diagnostics = server.FindNodeManagers<IDiagnosticsNodeManager>().Single();
|
|
HistoryServerCapabilitiesState history = await diagnostics
|
|
.GetDefaultHistoryCapabilitiesAsync(cancellationToken).ConfigureAwait(false);
|
|
history.MaxReturnDataValues!.Value = 73;
|
|
history.MaxReturnEventValues!.Value = 29;
|
|
observations.HistoryNodeId = history.NodeId;
|
|
}
|
|
}
|
|
|
|
private sealed class ExistingStartupTask(CompositionObservations observations) : IServerStartupTask
|
|
{
|
|
public ValueTask OnServerStartedAsync(
|
|
IServerContext server,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
Assert.That(server.CurrentState, Is.EqualTo(ServerState.Running));
|
|
observations.Events.Enqueue("existing");
|
|
return default;
|
|
}
|
|
}
|
|
|
|
public sealed class DelayedNodeManagerFactory : IAsyncNodeManagerFactory
|
|
{
|
|
public const string NamespaceUri = "urn:composition:delayed";
|
|
|
|
public DelayedNodeManagerFactory(CompositionObservations observations)
|
|
{
|
|
observations.FactoryConstructions++;
|
|
observations.Factory = this;
|
|
observations.FactorySawLoadedConfiguration = observations.ConfigurationLoaded;
|
|
}
|
|
|
|
public ArrayOf<string> NamespacesUris => [NamespaceUri];
|
|
public MarkerNodeManager Manager { get; private set; } = null!;
|
|
|
|
public ValueTask<IAsyncNodeManager> CreateAsync(
|
|
IServerInternal server,
|
|
ApplicationConfiguration configuration,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
Manager = new MarkerNodeManager(server, configuration, NamespaceUri, 51);
|
|
return new ValueTask<IAsyncNodeManager>(Manager);
|
|
}
|
|
}
|
|
|
|
private sealed class MarkerNodeManagerFactory(string namespaceUri, int marker) :
|
|
IAsyncNodeManagerFactory, IDisposable
|
|
{
|
|
public ArrayOf<string> NamespacesUris => [namespaceUri];
|
|
public int CreateCount { get; private set; }
|
|
public int DisposeCount { get; private set; }
|
|
public ApplicationConfiguration Configuration { get; private set; } = null!;
|
|
public MarkerNodeManager Manager { get; private set; } = null!;
|
|
|
|
public ValueTask<IAsyncNodeManager> CreateAsync(
|
|
IServerInternal server,
|
|
ApplicationConfiguration configuration,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
CreateCount++;
|
|
Configuration = configuration;
|
|
Manager = new MarkerNodeManager(server, configuration, namespaceUri, marker);
|
|
return new ValueTask<IAsyncNodeManager>(Manager);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
DisposeCount++;
|
|
}
|
|
}
|
|
|
|
public sealed class MarkerNodeManager(
|
|
IServerInternal server,
|
|
ApplicationConfiguration configuration,
|
|
string namespaceUri,
|
|
int marker) : AsyncCustomNodeManager(server, configuration, NullLogger.Instance, namespaceUri)
|
|
{
|
|
public bool Disposed { get; private set; }
|
|
|
|
public override async ValueTask CreateAddressSpaceAsync(
|
|
IDictionary<NodeId, IList<IReference>> externalReferences,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var variable = new BaseDataVariableState(null)
|
|
{
|
|
NodeId = new NodeId("Marker", NamespaceIndex),
|
|
BrowseName = new QualifiedName("Marker", NamespaceIndex),
|
|
DisplayName = LocalizedText.From("Marker"),
|
|
TypeDefinitionId = VariableTypeIds.BaseDataVariableType,
|
|
DataType = DataTypeIds.Int32,
|
|
ValueRank = ValueRanks.Scalar,
|
|
AccessLevel = AccessLevels.CurrentRead,
|
|
UserAccessLevel = AccessLevels.CurrentRead,
|
|
Value = marker
|
|
};
|
|
variable.AddReference(ReferenceTypeIds.Organizes, true, ObjectIds.ObjectsFolder);
|
|
if (!externalReferences.TryGetValue(ObjectIds.ObjectsFolder, out IList<IReference>? references))
|
|
{
|
|
references = [];
|
|
externalReferences[ObjectIds.ObjectsFolder] = references;
|
|
}
|
|
references.Add(new NodeStateReference(ReferenceTypeIds.Organizes, false, variable.NodeId));
|
|
await AddPredefinedNodeAsync(SystemContext, variable, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
protected override void Dispose(bool disposing)
|
|
{
|
|
if (disposing)
|
|
{
|
|
Disposed = true;
|
|
}
|
|
base.Dispose(disposing);
|
|
}
|
|
}
|
|
|
|
private sealed class LegacyMarkerNodeManager(
|
|
IServerInternal server,
|
|
ApplicationConfiguration configuration) :
|
|
CustomNodeManager2(server, configuration, NullLogger.Instance, "urn:composition:sync-instance")
|
|
{
|
|
public bool Disposed { get; private set; }
|
|
|
|
public override void CreateAddressSpace(IDictionary<NodeId, IList<IReference>> externalReferences)
|
|
{
|
|
var variable = new BaseDataVariableState(null)
|
|
{
|
|
NodeId = new NodeId("Marker", NamespaceIndex),
|
|
BrowseName = new QualifiedName("Marker", NamespaceIndex),
|
|
DisplayName = LocalizedText.From("Legacy Marker"),
|
|
TypeDefinitionId = VariableTypeIds.BaseDataVariableType,
|
|
DataType = DataTypeIds.Int32,
|
|
ValueRank = ValueRanks.Scalar,
|
|
AccessLevel = AccessLevels.CurrentRead,
|
|
UserAccessLevel = AccessLevels.CurrentRead,
|
|
Value = 22
|
|
};
|
|
AddPredefinedNode(SystemContext, variable);
|
|
}
|
|
|
|
protected override void Dispose(bool disposing)
|
|
{
|
|
if (disposing)
|
|
{
|
|
Disposed = true;
|
|
}
|
|
base.Dispose(disposing);
|
|
}
|
|
}
|
|
|
|
private sealed class ResourceHookServer(IServiceProvider services) : DependencyInjectionStandardServer(
|
|
services, NUnitTelemetryContext.Create(isServer: true), TimeProvider.System)
|
|
{
|
|
public ResourceManager CreateResources(ApplicationConfiguration configuration)
|
|
{
|
|
return CreateResourceManager(Mock.Of<IServerInternal>(), configuration);
|
|
}
|
|
}
|
|
|
|
private sealed class DisposalTrackingServer(ITelemetryContext telemetry) : StandardServer(telemetry)
|
|
{
|
|
public int DisposeCount { get; private set; }
|
|
|
|
protected override void Dispose(bool disposing)
|
|
{
|
|
if (disposing)
|
|
{
|
|
DisposeCount++;
|
|
}
|
|
base.Dispose(disposing);
|
|
}
|
|
}
|
|
|
|
private sealed class HostedFixture : IAsyncDisposable
|
|
{
|
|
public HostedFixture(Func<IServiceCollection, string, IOpcUaServerBuilder> configure)
|
|
{
|
|
m_root = Path.Combine(Path.GetTempPath(), "uac", Guid.NewGuid().ToString("N")[..8]);
|
|
Directory.CreateDirectory(m_root);
|
|
var services = new ServiceCollection();
|
|
services.AddLogging(builder => builder.AddProvider(Logs));
|
|
services.AddSingleton(NUnitTelemetryContext.Create(isServer: true));
|
|
IOpcUaServerBuilder builder = configure(services, m_root);
|
|
builder.AddStartupTask((_, context, _) =>
|
|
{
|
|
Context = context;
|
|
m_ready.TrySetResult(true);
|
|
return default;
|
|
});
|
|
var bindings = new DefaultTransportBindingRegistry();
|
|
bindings.RegisterListenerFactory(Transport);
|
|
services.AddSingleton<ITransportBindingRegistry>(bindings);
|
|
m_provider = services.BuildServiceProvider();
|
|
_ = HostedService;
|
|
}
|
|
|
|
public static HostedFixture Create(
|
|
Action<IOpcUaServerBuilder> compose = null!,
|
|
Action<OpcUaServerOptions> configure = null!)
|
|
{
|
|
return new HostedFixture((services, root) =>
|
|
{
|
|
IOpcUaServerBuilder builder = services.AddOpcUa().AddServer(options =>
|
|
{
|
|
ConfigureOptions(options, root);
|
|
configure?.Invoke(options);
|
|
});
|
|
compose?.Invoke(builder);
|
|
return builder;
|
|
});
|
|
}
|
|
|
|
public IServerContext Context { get; private set; } = null!;
|
|
public RecordingTransportFactory Transport { get; } = new();
|
|
public CapturingLoggerProvider Logs { get; } = new();
|
|
public IServiceProvider Services => m_provider;
|
|
public StandardServer Server => Transport.Server;
|
|
public ApplicationConfiguration Configuration => Transport.Configuration;
|
|
public Task ExecuteTask => HostedService.ExecuteTask!;
|
|
|
|
private BackgroundService HostedService => m_provider.GetServices<IHostedService>()
|
|
.OfType<BackgroundService>().Single();
|
|
|
|
public async Task StartAsync()
|
|
{
|
|
await HostedService.StartAsync(CancellationToken.None).ConfigureAwait(false);
|
|
Task<Task> waiting = Task.WhenAny(m_ready.Task, ExecuteTask);
|
|
await AwaitBoundedAsync(waiting).ConfigureAwait(false);
|
|
Task completion = await waiting.ConfigureAwait(false);
|
|
await completion.ConfigureAwait(false);
|
|
if (completion != m_ready.Task)
|
|
{
|
|
throw new InvalidOperationException("The hosted service stopped before completing startup.");
|
|
}
|
|
}
|
|
|
|
public async Task<Exception> StartAndCaptureFailureAsync()
|
|
{
|
|
try
|
|
{
|
|
await HostedService.StartAsync(CancellationToken.None).ConfigureAwait(false);
|
|
await AwaitBoundedAsync(ExecuteTask).ConfigureAwait(false);
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
return exception;
|
|
}
|
|
throw new InvalidOperationException("Expected hosted startup to fail.");
|
|
}
|
|
|
|
public async Task StopAsync()
|
|
{
|
|
if (m_stopped)
|
|
{
|
|
return;
|
|
}
|
|
m_stopped = true;
|
|
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(20));
|
|
await HostedService.StopAsync(timeout.Token).ConfigureAwait(false);
|
|
}
|
|
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
try
|
|
{
|
|
await StopAsync().ConfigureAwait(false);
|
|
}
|
|
finally
|
|
{
|
|
try
|
|
{
|
|
await m_provider.DisposeAsync().ConfigureAwait(false);
|
|
}
|
|
finally
|
|
{
|
|
if (Directory.Exists(m_root))
|
|
{
|
|
Directory.Delete(m_root, recursive: true);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private readonly string m_root;
|
|
private readonly ServiceProvider m_provider;
|
|
private readonly TaskCompletionSource<bool> m_ready = NewSignal<bool>();
|
|
private bool m_stopped;
|
|
}
|
|
|
|
private sealed class RecordingTransportFactory : TcpServiceHost, ITransportListenerFactory
|
|
{
|
|
public RecordingTransportFactory()
|
|
{
|
|
Listener.SetupGet(listener => listener.ListenerId).Returns("composition");
|
|
Listener.SetupGet(listener => listener.UriScheme).Returns(Utils.UriSchemeOpcTcp);
|
|
}
|
|
|
|
public Mock<ITransportListener> Listener { get; } = new();
|
|
public StandardServer Server { get; private set; } = null!;
|
|
public ApplicationConfiguration Configuration { get; private set; } = null!;
|
|
public override string UriScheme => Utils.UriSchemeOpcTcp;
|
|
|
|
public override ITransportListener Create(ITelemetryContext telemetry)
|
|
{
|
|
return Listener.Object;
|
|
}
|
|
|
|
ValueTask<List<EndpointDescription>> ITransportListenerFactory.CreateServiceHostAsync(
|
|
ServerBase serverBase,
|
|
IDictionary<string, ServiceHost> hosts,
|
|
ApplicationConfiguration configuration,
|
|
ArrayOf<string> baseAddresses,
|
|
ApplicationDescription serverDescription,
|
|
ArrayOf<ServerSecurityPolicy> securityPolicies,
|
|
ICertificateRegistry serverCertificates,
|
|
ICertificateValidatorEx clientCertificateValidator,
|
|
CancellationToken ct)
|
|
{
|
|
Server = (StandardServer)serverBase;
|
|
Configuration = configuration;
|
|
return CreateServiceHostAsync(serverBase, hosts, configuration, baseAddresses,
|
|
serverDescription, securityPolicies, serverCertificates, clientCertificateValidator, ct);
|
|
}
|
|
}
|
|
|
|
private sealed class CapturingLoggerProvider : ILoggerProvider
|
|
{
|
|
public ConcurrentQueue<Exception> Errors { get; } = new();
|
|
|
|
public ILogger CreateLogger(string categoryName)
|
|
{
|
|
return new CapturingLogger(Errors);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
}
|
|
}
|
|
|
|
private sealed class CapturingLogger(ConcurrentQueue<Exception> errors) : ILogger
|
|
{
|
|
public IDisposable BeginScope<TState>(TState state) where TState : notnull
|
|
{
|
|
return NoopDisposable.Instance;
|
|
}
|
|
|
|
public bool IsEnabled(LogLevel logLevel)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
public void Log<TState>(
|
|
LogLevel logLevel,
|
|
EventId eventId,
|
|
TState state,
|
|
Exception? exception,
|
|
Func<TState, Exception, string> formatter)
|
|
{
|
|
if (logLevel == LogLevel.Error && exception != null)
|
|
{
|
|
errors.Enqueue(exception);
|
|
}
|
|
}
|
|
}
|
|
|
|
private sealed class NoopDisposable : IDisposable
|
|
{
|
|
public static NoopDisposable Instance { get; } = new();
|
|
|
|
public void Dispose()
|
|
{
|
|
}
|
|
}
|
|
|
|
[TestCase(false)]
|
|
[TestCase(true)]
|
|
public void IdentityFactoryReceivesTheSuppliedNullableValidator(bool supplyValidator)
|
|
{
|
|
ICertificateValidatorEx validator = supplyValidator ? Mock.Of<ICertificateValidatorEx>() : null!;
|
|
var observations = new CompositionObservations();
|
|
var authenticator = new AnonymousAuthenticator();
|
|
int calls = 0;
|
|
IOpcUaServerBuilder builder = CreateBuilder();
|
|
builder.Services.AddSingleton(observations);
|
|
builder.AddIdentityAuthenticator((services, certificateValidator) =>
|
|
{
|
|
Assert.That(services.GetRequiredService<CompositionObservations>(), Is.SameAs(observations));
|
|
Assert.That(certificateValidator, Is.SameAs(validator));
|
|
calls++;
|
|
return authenticator;
|
|
});
|
|
using ServiceProvider provider = builder.Services.BuildServiceProvider();
|
|
OpcUaServerIdentityAuthenticatorRegistration registration = provider
|
|
.GetServices<OpcUaServerIdentityAuthenticatorRegistration>().Last();
|
|
Assert.That(calls, Is.Zero);
|
|
|
|
IUserTokenAuthenticator[] authenticators =
|
|
[.. registration.CreateAuthenticators(provider, validator)];
|
|
|
|
Assert.That(authenticators, Has.Length.EqualTo(1));
|
|
Assert.That(authenticators[0], Is.SameAs(authenticator));
|
|
Assert.That(calls, Is.EqualTo(1));
|
|
}
|
|
|
|
[Test]
|
|
public async Task ThrowingFactoryRunsAfterCertificateSetupAndPreservesItsFailureAsync()
|
|
{
|
|
var expected = new InvalidOperationException("configuration-factory-failure");
|
|
int calls = 0;
|
|
int laterFactoryCalls = 0;
|
|
int startupCalls = 0;
|
|
var fixture = HostedFixture.Create(builder => builder
|
|
.AddNodeManagers((_, configuration) =>
|
|
{
|
|
using CertificateEntryCollection certificates = configuration.CertificateManager
|
|
.SnapshotApplicationCertificates();
|
|
Assert.That(certificates, Has.Count.GreaterThan(0));
|
|
Assert.That(configuration.ApplicationName, Is.EqualTo("CompositionServer"));
|
|
calls++;
|
|
throw expected;
|
|
})
|
|
.AddNodeManagers((_, _) =>
|
|
{
|
|
laterFactoryCalls++;
|
|
return [];
|
|
})
|
|
.AddStartupTask((_, _, _) =>
|
|
{
|
|
startupCalls++;
|
|
return default;
|
|
}));
|
|
await using ConfiguredAsyncDisposable cleanup = fixture.ConfigureAwait(false);
|
|
|
|
Exception actual = await fixture.StartAndCaptureFailureAsync().ConfigureAwait(false);
|
|
|
|
Assert.That(actual, Is.SameAs(expected));
|
|
Assert.That(fixture.ExecuteTask.IsFaulted, Is.True);
|
|
Assert.That(calls, Is.EqualTo(1));
|
|
Assert.That(laterFactoryCalls, Is.Zero);
|
|
Assert.That(startupCalls, Is.Zero);
|
|
Assert.That(fixture.Transport.Server, Is.Null);
|
|
}
|
|
|
|
[Test]
|
|
public async Task StartupFailureStopsTheReverseTimerBeforeExecuteTaskFaultsAsync()
|
|
{
|
|
var expected = new InvalidOperationException("reverse-startup-failure");
|
|
var clock = new FakeTimeProvider();
|
|
var client = new Uri("opc.tcp://localhost:0/startup-failure");
|
|
var fixture = HostedFixture.Create(
|
|
builder =>
|
|
{
|
|
builder.Services.AddSingleton<TimeProvider>(clock);
|
|
builder.AddStartupTask((_, context, _) =>
|
|
{
|
|
Assert.That(context.CurrentState, Is.EqualTo(ServerState.Running));
|
|
throw expected;
|
|
});
|
|
},
|
|
options =>
|
|
{
|
|
options.ReverseConnect = new ServerReverseConnectOptions { ConnectIntervalMs = 2000 };
|
|
options.ReverseConnect.Clients.Add(new ServerReverseConnectClientOptions
|
|
{
|
|
EndpointUrl = client.ToString(),
|
|
Timeout = 1456,
|
|
MaxSessionCount = 0
|
|
});
|
|
});
|
|
await using ConfiguredAsyncDisposable cleanup = fixture.ConfigureAwait(false);
|
|
|
|
Exception actual = await fixture.StartAndCaptureFailureAsync().ConfigureAwait(false);
|
|
Assert.That(actual, Is.SameAs(expected));
|
|
Assert.That(fixture.ExecuteTask.IsFaulted, Is.True);
|
|
Assert.That(fixture.Server, Is.InstanceOf<ReverseConnectServer>());
|
|
var server = (ReverseConnectServer)fixture.Server;
|
|
Assert.That(server.GetReverseConnections()[client].LastState, Is.EqualTo(ReverseConnectState.Closed));
|
|
|
|
// Do not call StopAsync first: ExecuteTask must already have completed its cleanup.
|
|
clock.Advance(TimeSpan.FromSeconds(10));
|
|
|
|
fixture.Transport.Listener.Verify(listener => listener.CreateReverseConnection(
|
|
It.IsAny<Uri>(), It.IsAny<int>()), Times.Never);
|
|
fixture.Transport.Listener.Verify(listener => listener.CloseAsync(
|
|
It.IsAny<CancellationToken>()), Times.AtLeastOnce);
|
|
Assert.That(server.GetReverseConnections()[client].LastState, Is.EqualTo(ReverseConnectState.Closed));
|
|
Assert.That(fixture.Logs.Errors, Does.Contain(expected));
|
|
}
|
|
|
|
[TestCase(false)]
|
|
[TestCase(true)]
|
|
public async Task LoadedReverseConnectionsUseDocumentAndLoadedOverridesRatherThanOptionsAsync(bool useStream)
|
|
{
|
|
var clock = new FakeTimeProvider();
|
|
var enabled = new Uri("opc.tcp://localhost:0/xml-enabled");
|
|
var disabled = new Uri("opc.tcp://localhost:0/xml-disabled");
|
|
var optionsOnly = new Uri("opc.tcp://localhost:0/options-only");
|
|
int overrides = 0;
|
|
var fixture = new HostedFixture((services, root) =>
|
|
{
|
|
services.AddSingleton<TimeProvider>(clock);
|
|
IOpcUaBuilder builder = services.AddOpcUa();
|
|
string configurationXml = CreateReverseConfigurationXml(root);
|
|
IOpcUaServerBuilder server;
|
|
if (useStream)
|
|
{
|
|
server = builder.AddServer(
|
|
new MemoryStream(Encoding.UTF8.GetBytes(configurationXml)),
|
|
OverrideReverseConnection);
|
|
}
|
|
else
|
|
{
|
|
string configurationFile = Path.Combine(root, "reverse.xml");
|
|
File.WriteAllText(configurationFile, configurationXml);
|
|
server = builder.AddServer(configurationFile, OverrideReverseConnection);
|
|
}
|
|
services.Configure<OpcUaServerOptions>(options =>
|
|
{
|
|
options.ReverseConnect = new ServerReverseConnectOptions { ConnectIntervalMs = 500 };
|
|
options.ReverseConnect.Clients.Add(new ServerReverseConnectClientOptions
|
|
{
|
|
EndpointUrl = optionsOnly.ToString()
|
|
});
|
|
});
|
|
return server;
|
|
});
|
|
await using ConfiguredAsyncDisposable cleanup = fixture.ConfigureAwait(false);
|
|
await fixture.StartAsync().ConfigureAwait(false);
|
|
|
|
Assert.That(overrides, Is.EqualTo(1));
|
|
Assert.That(fixture.Server, Is.InstanceOf<ReverseConnectServer>());
|
|
var reverse = (ReverseConnectServer)fixture.Server;
|
|
Assert.That(reverse.GetReverseConnections().Keys, Is.EquivalentTo([enabled, disabled]));
|
|
Assert.That(reverse.GetReverseConnections()[enabled].ConfigEntry, Is.True);
|
|
Assert.That(reverse.GetReverseConnections()[enabled].Timeout, Is.EqualTo(2345));
|
|
Assert.That(reverse.GetReverseConnections()[disabled].Enabled, Is.False);
|
|
Assert.That(fixture.Configuration.ServerConfiguration!.ReverseConnect!.ConnectTimeout, Is.EqualTo(6000));
|
|
Assert.That(ReadBuildInfo(fixture.Context).ProductName, Is.EqualTo("XmlComposition"));
|
|
|
|
clock.Advance(TimeSpan.FromMilliseconds(500));
|
|
fixture.Transport.Listener.Verify(listener => listener.CreateReverseConnection(
|
|
It.IsAny<Uri>(), It.IsAny<int>()), Times.Never);
|
|
clock.Advance(TimeSpan.FromMilliseconds(1000));
|
|
fixture.Transport.Listener.Verify(listener => listener.CreateReverseConnection(enabled, 2345), Times.Once);
|
|
fixture.Transport.Listener.Verify(listener => listener.CreateReverseConnection(
|
|
disabled, It.IsAny<int>()), Times.Never);
|
|
fixture.Transport.Listener.Verify(listener => listener.CreateReverseConnection(
|
|
optionsOnly, It.IsAny<int>()), Times.Never);
|
|
|
|
void OverrideReverseConnection(ApplicationConfiguration configuration)
|
|
{
|
|
overrides++;
|
|
ReverseConnectServerConfiguration loaded = configuration.ServerConfiguration!.ReverseConnect!;
|
|
Assert.That(loaded!.Clients.Count, Is.EqualTo(2));
|
|
Assert.That(loaded.ConnectInterval, Is.EqualTo(3000));
|
|
Assert.That(loaded.Clients[0].Timeout, Is.EqualTo(1234));
|
|
loaded.ConnectInterval = 1500;
|
|
loaded.Clients[0].Timeout = 2345;
|
|
}
|
|
}
|
|
|
|
private static string CreateReverseConfigurationXml(string root)
|
|
{
|
|
const string reverseXml = """
|
|
<ReverseConnect>
|
|
<Clients>
|
|
<ReverseConnectClient>
|
|
<EndpointUrl>opc.tcp://localhost:0/xml-enabled</EndpointUrl>
|
|
<Timeout>1234</Timeout>
|
|
<MaxSessionCount>0</MaxSessionCount>
|
|
<Enabled>true</Enabled>
|
|
</ReverseConnectClient>
|
|
<ReverseConnectClient>
|
|
<EndpointUrl>opc.tcp://localhost:0/xml-disabled</EndpointUrl>
|
|
<Timeout>4321</Timeout>
|
|
<MaxSessionCount>0</MaxSessionCount>
|
|
<Enabled>false</Enabled>
|
|
</ReverseConnectClient>
|
|
</Clients>
|
|
<ConnectInterval>3000</ConnectInterval>
|
|
<ConnectTimeout>6000</ConnectTimeout>
|
|
<RejectTimeout>9000</RejectTimeout>
|
|
</ReverseConnect>
|
|
""";
|
|
string xml = CreateConfigurationXml(root);
|
|
int closingTag = xml.IndexOf("</ServerConfiguration>", StringComparison.Ordinal);
|
|
return xml.Insert(closingTag, reverseXml);
|
|
}
|
|
|
|
private static readonly string[] s_completedEvents = ["completed"];
|
|
private static readonly string[] s_enteredEvents = ["existing", "generic", "delegate-entered"];
|
|
|
|
private static readonly string[] s_startupEvents =
|
|
["existing", "generic", "delegate-entered", "delegate-completed", "last"];
|
|
|
|
private static readonly string[] s_resourceEvents = ["first", "second"];
|
|
private static readonly string[] s_mutatedAliases = ["BuildName", "BuildNumber"];
|
|
private static readonly UserTokenType[] s_anonymousTokens = [UserTokenType.Anonymous];
|
|
}
|
|
}
|