Files
UA-.NETStandard/tests/Opc.Ua.Client.TestFramework/TestFixture.cs
Marc SchierandCopilot fac30c45e6 Add MinSupportedSamplingInterval configuration and honor it when revising sampling intervals (#4243)
# Description

PR #4194 removed the unused
`ServerConfiguration.MinMetadataSamplingInterval` and left nothing in
its place. As a result there was no way to populate
`Server.ServerCapabilities.MinSupportedSampleRate` (`i=2272`) —
`ServerInternalData.CreateServerObjectAsync` hardcoded it to `0`.
Separately, the only clamp applied when revising a monitored item's
sampling interval was the per-node
`BaseVariableState.MinimumSamplingInterval`, which defaults to
`MinimumSamplingIntervals.Continuous` (`0`), so reference-server nodes
accepted any requested interval (e.g. 10 ms) unrevised — exactly what
was reported.

This adds a configuration entry **in the exact schema slot #4194
vacated** and enforces it as a lower bound.

## Configuration

- **`ServerConfiguration.MinSupportedSamplingInterval`** — `double`
(milliseconds), default `0`, `[DataTypeField(Order = 17)]`, i.e. between
`MaxNotificationsPerPublish` (16) and `AvailableSamplingRates` (18).
- `ApplicationConfiguration.xsd` — `<xs:element
name="MinSupportedSamplingInterval" type="xs:double" minOccurs="0" />`
in the same position the removed element occupied.
- Fluent API **`SetMinSupportedSamplingInterval(double)`** on
`IApplicationConfigurationBuilderServerOptions`, in the place
`SetMinMetadataSamplingInterval` was removed from.
- The element is added to the six config XML files #4194 touched
(reference / CTT / AOT / GDS use `0`; `test-server-full.xml` uses a
distinctive non-zero value for the round-trip test).

The address-space Property keeps its OPC UA spec name, so
`Server.ServerCapabilities.MinSupportedSampleRate` and
`VariableIds.Server_ServerCapabilities_MinSupportedSampleRate` are
unchanged.

```xml
<MaxNotificationsPerPublish>1000</MaxNotificationsPerPublish>
<MinSupportedSamplingInterval>2000</MinSupportedSamplingInterval>
<AvailableSamplingRates>
```

```csharp
application.Build(applicationUri, productUri)
    .AsServer([endpointUrl])
    .SetMinSupportedSamplingInterval(2000);
```

## Behavior

- `ServerInternalData` now publishes the configured value to
`Server.ServerCapabilities.MinSupportedSampleRate`.
- New `SubscriptionManager.CalculateRevisedSamplingInterval(...)` (two
overloads, alongside the existing `CalculateRevisedQueueSize`)
centralizes the revision rule and replaces the logic that was duplicated
across four call sites — the create and modify paths of
`AsyncCustomNodeManager` and `CustomNodeManager` — which now expose a
`MinSupportedSamplingInterval` property seeded from the configuration
(mirroring how `MaxQueueSize` is initialized).

The rule:

1. A requested interval below zero resolves to the *default sampling
interval*: the **publishing interval of the subscription** on create,
and the item's **current sampling interval** on modify. The modify case
preserves 1.5.378 behavior, so a `ModifyMonitoredItems` call that leaves
the sampling interval unspecified does not silently retune the item.
2. A node declaring `MinimumSamplingIntervals.Continuous` (`0`) for the
`Value` Attribute reports **by exception** and is **not** bound by the
server-wide minimum — the requested interval is returned unchanged.
3. Every other item is raised to `max(nodeMinimumSamplingInterval,
MinSupportedSamplingInterval)`. Attributes other than `Value`, nodes
declaring `MinimumSamplingIntervals.Indeterminate` (`-1`), and
non-Variable nodes are bound only by `MinSupportedSamplingInterval`.
4. `double.MaxValue` is capped to one year (pre-existing behavior, now
in one place).

Event monitored items do not sample and are unaffected.

**Backward compatible:** with the default of `0` the computation is
identical to the previous code for every input, including `Continuous`,
`Indeterminate`, positive node minimums, non-`Value` attributes and
`NaN`. No existing configuration or deployment changes behavior.

Node managers that implement `INodeManager` directly can call the new
public helper to apply the same rule.

## Related Issues

- Fixes #4191

The issue thread also asked whether
`Subscription.MaxNotificationsPerPublish` is revised by the server. It
is (`SubscriptionManager.CalculateMaxNotificationsPerPublish`), but it
was untested and is not observable from the `CreateSubscription`
response, which carries no revised field for that parameter. Four tests
were added that assert it through
`SubscriptionDiagnostics.MaxNotificationsPerPublish` on a privileged
session, covering create and modify, the above-limit, below-limit and
`0` (server default) cases.

## Tests

- **`SamplingIntervalRevisionTests`** (17 unit tests,
`Opc.Ua.Server.Tests`) — the full revision matrix: negative request
resolving to the default interval, the `Continuous` bypass,
`Indeterminate`, node minimum above *and* below the floor, a floor of
`0` being a no-op, non-`Value` attributes, non-Variable and null nodes,
and the `double.MaxValue` cap.
- **`MinSupportedSamplingIntervalTests`** (10 integration tests,
`Opc.Ua.Subscriptions.Tests`) — against a reference server configured
with a 500 ms floor: the capabilities node reflects the configuration; a
node declaring 100 ms is lifted to 500 ms;
`Scalar_Static_Arrays2D_LocalizedText` (1000 ms) wins over the floor;
`CurrentTime` (250 ms) is lifted; a `Continuous` node keeps its 10 ms; a
non-`Value` attribute is floored; `ModifyMonitoredItems` behaves the
same in both directions; event items are unaffected.
- Restored the configuration builder and round-trip test coverage that
#4194 deleted.
- `TestFixture` gained a `protected virtual void
ConfigureServer(ApplicationConfiguration)` hook so a derived fixture can
vary the server configuration before start. Default implementation is a
no-op, so no existing fixture changes behavior.

## Documentation

- `docs/Subscriptions.md` — new **Sampling interval revision** section
with the input table, the four-step rule (including both
negative-interval fallbacks), XML and fluent configuration examples, and
guidance for custom node managers.
- `docs/migrate/2.0.x/configuration.md` — new section documenting that
`MinMetadataSamplingInterval` was removed and
`MinSupportedSamplingInterval` added. Verified against `master378`: the
old setting defaulted to `1000` but was never read, and
`MinSupportedSampleRate` was hardcoded to `0` with no server-wide floor.
The guide therefore stresses that these are **two different settings,
not a rename** — consumers should drop the old element (a default of `0`
reproduces 1.5.378 exactly) and only opt into the new one deliberately,
since reusing the old value would silently introduce a sampling floor.

## Validation

- `dotnet build UA.slnx` — **0 warnings, 0 errors** across all TFMs.
- .NET 10: `Opc.Ua.Server.Tests` 4091 passed,
`Opc.Ua.Subscriptions.Tests` 619 passed (full suite including the new
fixture), `Opc.Ua.InformationModel.Tests` 1063 passed,
`Opc.Ua.Configuration.Tests` 221 passed, `Opc.Ua.Gds.Tests` 119 passed.
- .NET Framework 4.8: new unit tests (200 incl. sampling groups), new
integration tests (17) and configuration round-trip (135) all passed.
- `ConsoleReferenceServer` starts successfully with the updated
configuration file.

## Checklist

- [x] 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.
- [x] 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.
- [x] 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.
- [x] I have addressed **all** PR feedback received.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 79f8090c-b2a3-409c-a5bf-56b6d2a35e70
2026-08-12 17:41:29 +00:00

587 lines
26 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;
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 all remaining 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.
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; }
public ClientFixture ClientFixture { get; private set; }
public ISession Session { get; protected set; }
public Uri ServerUrl { get; private set; }
public ReferenceServer ReferenceServer { get; private set; }
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; }
/// <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;
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;
}
}