Files
romanett 9b034f8291 Enable nullable reference types centrally; migrate all unset and annotations-only projects (#4700)
# 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>
2026-10-08 08:48:13 +02:00

618 lines
28 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ========================================================================
* Copyright (c) 2005-2025 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.Globalization;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using NUnit.Framework;
using Opc.Ua.Server.TestFramework;
using Opc.Ua.Tests;
using Quickstarts.ReferenceServer;
namespace Opc.Ua.Client.TestFramework
{
/// <summary>
/// Base class for compliance tests. Starts an in-process ReferenceServer
/// and connects a client session for use by derived test classes.
/// </summary>
public abstract class TestFixture
{
[OneTimeSetUp]
public async Task OneTimeSetUp()
{
m_pkiRoot = Path.GetTempPath() + Path.GetRandomFileName();
if (m_logger.IsEnabled(LogLevel.Information))
{
m_logger.LogInformation("Test PkiRoot: {PkiRoot}", m_pkiRoot);
}
// Start in-process ReferenceServer with the optional conformance
// node managers enabled (Part 17 AliasName + FileSystem). These
// are off by default so the standard test fixtures keep a small
// address space; conformance tests need them.
ServerFixture = new ServerFixture<ReferenceServer>(
t => new ReferenceServer(t) { EnableFileSystemNodeManager = true })
{
AutoAccept = true,
SecurityNone = true,
OperationLimits = true,
AllNodeManagers = true
};
await ServerFixture.LoadConfigurationAsync(m_pkiRoot).ConfigureAwait(false);
ServerFixture.Config.TransportQuotas!.MaxMessageSize = TransportQuotaMaxMessageSize;
ServerFixture.Config.TransportQuotas.MaxByteStringLength =
ServerFixture.Config.TransportQuotas.MaxStringLength = TransportQuotaMaxStringLength;
// Enable all user token types so security tests can authenticate
ServerFixture.Config.ServerConfiguration!.UserTokenPolicies =
new UserTokenPolicy[] {
new(UserTokenType.Anonymous),
new(UserTokenType.UserName),
new(UserTokenType.Certificate)
}.ToArrayOf();
// Enable durable subscriptions so SubscriptionDurableTests
// (SetSubscriptionDurable / TransferSubscriptions on durable subs)
// can exercise the durable code paths instead of skipping with
// BadNotSupported.
ServerFixture.Config.ServerConfiguration.DurableSubscriptionsEnabled = true;
// Enable server diagnostics so BaseInfoBehavioralTests can read
// Server.ServerDiagnostics.* arrays, SamplingIntervalDiagnosticsArray,
// SessionSecurityDiagnosticsArray, etc.
ServerFixture.Config.ServerConfiguration.DiagnosticsEnabled = true;
// Enable auditing so AuditingOperationTests can verify audit
// event emission for CreateSession / ActivateSession /
// CloseSession.
ServerFixture.Config.ServerConfiguration.AuditingEnabled = true;
// Let a derived fixture adjust the server configuration before the
// server is started.
ConfigureServer(ServerFixture.Config);
ReferenceServer = await ServerFixture.StartAsync().ConfigureAwait(false);
// Attach the mock response controller so individual tests
// can inject service-result error codes or mutate response
// fields. Production servers leave ResponseMutator null;
// installing the controller is a test-only behaviour.
MockController = new MockResponseController();
ReferenceServer.ResponseMutator = MockController;
// Activate alarm sources so Alarms & Conditions tests have live
// alarm instances to inspect.
await Quickstarts.Servers.Utils.ApplyCTTModeAsync(
TextWriter.Null, ReferenceServer).ConfigureAwait(false);
// Seed default identity-mapping rules so role-based conformance
// tests can authenticate as admin via the sysadmin/demo credentials.
// The rules are in-memory and do not persist across restart.
Server.IRoleManager roleManager = (ReferenceServer.CurrentInstance?.RoleManager)!;
if (roleManager != null)
{
roleManager.AddIdentity(
ObjectIds.WellKnownRole_SecurityAdmin,
new IdentityMappingRuleType
{
CriteriaType = IdentityCriteriaType.UserName,
Criteria = "sysadmin"
});
roleManager.AddIdentity(
ObjectIds.WellKnownRole_ConfigureAdmin,
new IdentityMappingRuleType
{
CriteriaType = IdentityCriteriaType.UserName,
Criteria = "sysadmin"
});
}
ServerUrl = new Uri(
Utils.UriSchemeOpcTcp +
"://localhost:" +
ServerFixture.Port.ToString(CultureInfo.InvariantCulture));
if (m_logger.IsEnabled(LogLevel.Information))
{
m_logger.LogInformation("Server started at {Url}", ServerUrl);
}
// Create client fixture and connect session
ClientFixture = new ClientFixture(telemetry: Telemetry);
await ClientFixture.LoadClientConfigurationAsync(m_pkiRoot).ConfigureAwait(false);
ClientFixture.Config.TransportQuotas!.MaxMessageSize = TransportQuotaMaxMessageSize;
ClientFixture.Config.TransportQuotas.MaxByteStringLength =
ClientFixture.Config.TransportQuotas.MaxStringLength = TransportQuotaMaxStringLength;
// Slow CI runners need more SessionTimeout (server-side session
// lifetime) than the ClientFixture's 10 s default to keep the
// shared session alive across a long test suite. OperationTimeout
// stays at 30 s — long enough for slow Publish / CreateSubscription
// on a loaded runner but short enough that a test passing
// pathological input (e.g. a ReadProcessed with a negative
// ProcessingInterval that the server never answers) fails fast
// instead of hanging the whole testhost.
ClientFixture.SessionTimeout = 300_000;
ClientFixture.OperationTimeout = 90_000;
Session = await ClientFixture
.ConnectAsync(ServerUrl, SecurityPolicies.None)
.ConfigureAwait(false);
Assert.That(Session, Is.Not.Null, "Failed to create session");
}
[SetUp]
public async Task ResetServerLockoutState()
{
// Prior tests (especially negative auth tests) can trigger the
// SessionManager's failed-authentication lockout (5 attempts /
// 5 minute lockout). Clear it before each test so admin/user
// identities are usable again.
try
{
ReferenceServer?.CurrentInstance?.SessionManager?.ClearAuthenticationLockouts();
}
catch (Exception ex)
{
m_logger.LogError(ex, "Failed to clear authentication lockouts.");
}
// Clear any registered mock-response expectations so each test
// starts from a clean state.
MockController?.Reset();
// If a prior test poisoned the shared session (typical cascade
// pattern: one CreateSubscription / Publish timed out on a slow
// CI runner -> BadSessionIdInvalid on subsequent calls), re-open
// the session so the next test runs against a healthy fixture.
// First check the cheap Session.Connected flag; if that still
// reports true, do a 5 s health-check Read (Server status) to
// catch the case where the client thinks it's connected but
// the channel/server has gone away.
bool sessionDead = Session != null && !Session.Connected;
if (!sessionDead && Session != null)
{
try
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
await Session.ReadAsync(
null, 0, TimestampsToReturn.Neither,
new ReadValueId[]
{
new() { NodeId = new NodeId(Variables.Server_ServerStatus_State),
AttributeId = Attributes.Value }
}.ToArrayOf(),
cts.Token).ConfigureAwait(false);
}
catch (Exception ex)
{
m_logger.LogWarning(ex,
"Session health-check Read failed; treating as dead.");
sessionDead = true;
}
}
if (sessionDead)
{
try
{
m_logger.LogWarning(
"Session disconnected between tests; re-opening.");
Session!.Dispose();
Session = await ClientFixture
.ConnectAsync(ServerUrl, SecurityPolicies.None)
.ConfigureAwait(false);
}
catch (Exception ex)
{
m_logger.LogError(ex, "Failed to recover session in [SetUp].");
}
}
}
[OneTimeTearDown]
public async Task OneTimeTearDown()
{
// Once any fixture's teardown exceeds a watchdog, set this flag so all
// subsequent fixtures skip their teardown entirely and just null out
// their references. This converts the worst case from
// N_fixtures × watchdog_timeout (stacked)
// to a single watchdog timeout (the first hung fixture).
// Mirrors the same pattern in ServerFixture.StopAsync.
if (s_skipRemainingTeardowns)
{
m_logger.LogWarning(
"TestFixture.OneTimeTearDown: a prior fixture teardown timed out; " +
"skipping this teardown so the test host does not exceed the " +
"--blame-hang-timeout. References will be released for finalization.");
Session = null!;
if (ServerFixture != null)
{
try
{
// Call ServerFixture.StopAsync even in the skip path so certificate
// managers are disposed and the certificate-leak counter does not
// stay positive. ServerFixture.StopAsync has its own teardown watchdog.
await ServerFixture.StopAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
m_logger.LogError(ex, "Error stopping server during teardown (early-skip path).");
}
}
ServerFixture = null!;
ClientFixture = null!;
return;
}
if (Session != null)
{
// Use a hard wall-clock ceiling via Task.WhenAny so that teardown is
// bounded even when Session.CloseAsync catches the CancellationToken
// internally and continues running (e.g., in Session.Dispose() /
// socket-cleanup paths that have no CancellationToken of their own).
// On macOS CI, each stuck CloseAsync can run 15-20 s (10 s CTS fires
// → OCE caught → Dispose() called without CT → StopKeepAliveTimerAsync
// / transport teardown blocks). When the 15 s watchdog fires we set
// s_skipRemainingTeardowns so subsequent fixtures skip their teardown
// entirely rather than stacking another 15 s each.
bool sessionCloseCompleted = false;
try
{
using var sessionCloseCts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
Task<StatusCode> closeTask = Session.CloseAsync(5000, true, sessionCloseCts.Token);
var hardTimeoutTask = Task.Delay(TimeSpan.FromSeconds(15));
if (await Task.WhenAny(closeTask, hardTimeoutTask).ConfigureAwait(false) == hardTimeoutTask)
{
// CloseAsync is still running after 15 s (stuck in Dispose/socket
// cleanup). Signal remaining ops to give up and abandon the task.
sessionCloseCts.Cancel();
m_logger.LogWarning(
"Session.CloseAsync exceeded the 15 s teardown watchdog; " +
"abandoning and enabling skip-remaining-teardowns for this process.");
s_skipRemainingTeardowns = true;
}
else
{
// CloseAsync returned within budget; surface any exception.
await closeTask.ConfigureAwait(false);
sessionCloseCompleted = true;
}
}
catch (Exception ex)
{
m_logger.LogError(ex, "Error closing session during teardown.");
sessionCloseCompleted = true;
}
if (sessionCloseCompleted)
{
// CloseAsync(closeChannel: true) already called Dispose() internally,
// so this is a fast no-op (m_disposeAsyncCalled guard), but call it
// explicitly to satisfy any external IDisposable contracts.
Session.Dispose();
}
// If CloseAsync was abandoned, skip Dispose() – it would hit the same
// blocking code path. The abandoned task will finalize the session, or
// the OS will reclaim the socket when the test process exits.
Session = null!;
if (s_skipRemainingTeardowns)
{
// Skip session-dependent teardown steps for this fixture too; they would
// likely also block and there is no point in paying the cost since
// the process is going to exit anyway. Call ServerFixture.StopAsync()
// regardless: its own skip-remaining guard releases certificate managers
// and nulls the heavyweight references, which prevents the certificate-
// leak counter from staying positive and causing AssertNoCertificateLeaks
// to fail at the end of the assembly run.
if (ServerFixture != null)
{
try
{
await ServerFixture.StopAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
m_logger.LogError(ex, "Error stopping server during teardown (skip path).");
}
}
ServerFixture = null!;
ClientFixture = null!;
return;
}
}
if (ServerFixture != null)
{
try
{
await ServerFixture.StopAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
m_logger.LogError(ex, "Error stopping server during teardown.");
}
await Task.Delay(100).ConfigureAwait(false);
}
if (ClientFixture != null)
{
await ClientFixture.DisposeAsync().ConfigureAwait(false);
}
try
{
if (!string.IsNullOrEmpty(m_pkiRoot) && Directory.Exists(m_pkiRoot))
{
Directory.Delete(m_pkiRoot, true);
}
}
catch
{
// best-effort cleanup
}
}
public const int TransportQuotaMaxMessageSize = 4 * 1024 * 1024;
public const int TransportQuotaMaxStringLength = 1 * 1024 * 1024;
public ServerFixture<ReferenceServer> ServerFixture { get; private set; } = null!;
public ClientFixture ClientFixture { get; private set; } = null!;
public ISession Session { get; protected set; } = null!;
public Uri ServerUrl { get; private set; } = null!;
public ReferenceServer ReferenceServer { get; private set; } = null!;
public ITelemetryContext Telemetry { get; }
/// <summary>
/// Lets individual tests inject service-result error codes or
/// mutate response fields produced by the in-process reference
/// server. Reset between tests via <see cref="ResetServerLockoutState"/>.
/// </summary>
public MockResponseController MockController { get; private set; } = null!;
/// <summary>
/// Set by the first <see cref="OneTimeTearDown"/> that exceeds a watchdog
/// timeout. Subsequent fixtures' teardowns short-circuit to a no-op so
/// that N fixtures × watchdog_timeout cannot exceed the
/// --blame-hang-timeout. Mirrors the same pattern in
/// <c>ServerFixture.StopAsync</c>.
/// </summary>
private static volatile bool s_skipRemainingTeardowns;
private string m_pkiRoot = null!;
protected TestFixture()
{
Telemetry = NUnitTelemetryContext.Create();
m_logger = Telemetry.CreateLogger<TestFixture>();
}
/// <summary>
/// Lets a derived fixture adjust the configuration of the in-process reference
/// server before it is started. The default implementation does nothing.
/// </summary>
/// <param name="configuration">
/// The configuration of the server that is about to be started.
/// </param>
protected virtual void ConfigureServer(ApplicationConfiguration configuration)
{
}
/// <summary>
/// Returns <c>true</c> when the given <see cref="StatusCode"/> matches
/// one of the SDK channel-level transient codes that we ignore as
/// CI-runner load symptoms (the test logic itself is fine; the
/// channel/server simply didn't respond in time).
/// </summary>
protected static bool IsTransientCiTimeoutStatus(StatusCode code)
{
return code == StatusCodes.BadRequestTimeout ||
code == StatusCodes.BadRequestInterrupted ||
code == StatusCodes.BadConnectionClosed ||
code == StatusCodes.BadSecureChannelClosed ||
code == StatusCodes.BadSecurityChecksFailed
// BadSubscriptionIdInvalid 'Subscription belongs to a different session'
// is observed on the windows-latest Conformance runner when a test
// takes longer than the session timeout and the reconnect handler
// re-creates the session under it. The subscription handle becomes
// stale — that's an environmental side-effect of the slow runner,
// not a server defect.
|| code == StatusCodes.BadSubscriptionIdInvalid;
}
/// <summary>
/// Wraps <see cref="ISession.CreateSubscriptionAsync"/> for use from
/// <c>[SetUp]</c> methods so that a CI runner under heavy load doesn't
/// turn a transient channel-side timeout into a fixture-wide
/// failure. If the call throws one of the SDK channel-level transient
/// codes (BadRequestTimeout / BadRequestInterrupted /
/// BadConnectionClosed) the helper calls <see cref="Assert.Ignore(string)"/>,
/// which marks just the current test as Inconclusive and lets the
/// rest of the fixture proceed on the next CI cycle.
/// </summary>
protected async Task<uint> CreateSetupSubscriptionAsync(
double publishingInterval = 1000,
uint requestedLifetimeCount = 100,
uint requestedMaxKeepAliveCount = 10,
uint maxNotificationsPerPublish = 0,
bool publishingEnabled = true,
byte priority = 0)
{
try
{
CreateSubscriptionResponse response = await Session.CreateSubscriptionAsync(
null,
publishingInterval,
requestedLifetimeCount,
requestedMaxKeepAliveCount,
maxNotificationsPerPublish,
publishingEnabled,
priority,
CancellationToken.None).ConfigureAwait(false);
return response.SubscriptionId;
}
catch (ServiceResultException sre) when (IsTransientCiTimeoutStatus(sre.StatusCode))
{
Assert.Ignore(
$"Timing-sensitive: SetUp CreateSubscription interrupted by CI runner load ({sre.StatusCode}).");
return 0; // unreachable; Assert.Ignore throws.
}
}
/// <summary>
/// Connect as the seeded sysadmin user (granted SecurityAdmin and
/// ConfigureAdmin in TestFixture.OneTimeSetUp). Used by tests that
/// need to read admin-only attributes (RolePermissions /
/// UserRolePermissions / Diagnostics arrays / etc.) which the
/// standard nodeset hides from anonymous sessions via RolePermissions.
/// Returns null if no Sign+Encrypt or Sign endpoint with username
/// auth is exposed by the server — caller may Assert.Ignore.
/// </summary>
protected async Task<ISession> ConnectAsSysAdminAsync()
{
var endpointConfiguration = EndpointConfiguration.Create(ClientFixture.Config);
using DiscoveryClient client = await DiscoveryClient.CreateAsync(
ServerUrl,
endpointConfiguration,
Telemetry,
ct: CancellationToken.None).ConfigureAwait(false);
ArrayOf<EndpointDescription> endpoints = await client.GetEndpointsAsync(
default, CancellationToken.None).ConfigureAwait(false);
await client.CloseAsync(CancellationToken.None).ConfigureAwait(false);
string? policy = null;
foreach (MessageSecurityMode mode in new[]
{
MessageSecurityMode.SignAndEncrypt,
MessageSecurityMode.Sign,
MessageSecurityMode.None
})
{
foreach (EndpointDescription ep in endpoints)
{
if (ep.SecurityMode != mode)
{
continue;
}
if (ep.UserIdentityTokens == default!)
{
continue;
}
foreach (UserTokenPolicy t in ep.UserIdentityTokens)
{
if (t.TokenType == UserTokenType.UserName)
{
policy = ep.SecurityPolicyUri!;
break;
}
}
if (policy != null)
{
break;
}
}
if (policy != null)
{
break;
}
}
if (policy == null)
{
return null!;
}
return await ClientFixture.ConnectAsync(
ServerUrl, policy,
userIdentity: new UserIdentity("sysadmin", "demo"u8))
.ConfigureAwait(false);
}
/// <summary>
/// Opens a fresh, independent session to the in-process server,
/// bypassing the retry wrapper that the standard
/// <see cref="ClientFixture.ConnectAsync(Uri, string, ArrayOf{EndpointDescription}, IUserIdentity)"/>
/// applies. Used by RequiresServerMock tests that inject errors
/// into CreateSession / ActivateSession / CloseSession responses
/// — without this, the retry loop would consume the one-shot
/// expectation on the first attempt and the second attempt
/// would succeed unchanged, defeating the test.
/// </summary>
/// <param name="securityProfile">Security policy URI; defaults
/// to <see cref="SecurityPolicies.None"/>.</param>
/// <param name="userIdentity">Optional user identity.</param>
protected async Task<ISession> OpenAuxSessionAsync(
string? securityProfile = null,
IUserIdentity? userIdentity = null)
{
ConfiguredEndpoint endpoint = await ClientFixture.GetEndpointAsync(
ServerUrl,
securityProfile ?? SecurityPolicies.None).ConfigureAwait(false);
return await ClientFixture.ConnectAsync(endpoint, userIdentity).ConfigureAwait(false);
}
/// <summary>
/// Helper to resolve an ExpandedNodeId to a NodeId using the session namespace table.
/// </summary>
protected NodeId ToNodeId(ExpandedNodeId expandedNodeId)
{
return ExpandedNodeId.ToNodeId(expandedNodeId, Session.NamespaceUris);
}
/// <summary>
/// Calls Assert.Ignore when a role management method returns a status code
/// indicating the feature is not (fully) implemented in the test server.
/// BadEntryExists / BadAlreadyExists are treated as "server does not
/// implement idempotent re-add" — acceptable optional behavior per OPC UA Part 18.
/// </summary>
protected static void IgnoreIfRoleMethodNotSupported(StatusCode statusCode)
{
if (statusCode == StatusCodes.BadNotImplemented ||
statusCode == StatusCodes.BadServiceUnsupported ||
statusCode == StatusCodes.BadInvalidArgument ||
statusCode == StatusCodes.BadNotSupported ||
statusCode == StatusCodes.BadSecurityModeInsufficient ||
statusCode == StatusCodes.BadEntryExists ||
statusCode == StatusCodes.BadAlreadyExists)
{
Assert.Ignore(
"Role management method not fully implemented in test server: " +
$"{statusCode}");
}
}
private readonly ILogger m_logger;
}
}