Client/server transfer, WSS, and hosting fixes (#4802)

# Description

Layer 2 of the upstream non-UaLens extraction, with core/types
prerequisite #4801 (`marcschier-extract-core-and-types`). The initial
extraction commit `86ff10d3994f132f7343a929abdae209bc2e69b7` started
from `8881e096fb5e4eaa36f3c13f8caa57733373bd19` and copied exactly 13
client/server paths from #4462 at frozen head
`390c4a7a51b71da40caec2d22ef74518bbe670ce`, using its original base
`5b03e9afa492cdb36049ee52ae844f630f89d8db`. Those initial blobs were
source-identical; the core/types layer was not duplicated or rewritten.

- Add bounded temporary-file copying with byte/request limits, session
ownership checks, and independent bounded cleanup.
- Refresh revised monitored-item parameters after snapshot transfer and
notify the target handler after successful handle synchronization.
- Apply WSS opening deadlines while preserving caller cancellation, and
cover the wire envelope and request timeout behavior.
- Make hosted server presets honor role configuration without replacing
an explicitly registered role manager; include the corresponding tests
and documentation.

**Review follow-up:** commit `a5c8fe0884ad3346106529deba71d93c5df20a9a`
intentionally corrects three defects in the extracted bounded-copy
implementation. It captures one `SessionRequestContext` for managed
transfers, calls the standard `FileType.Close` declaration without
metadata discovery, and retains valid generation handles before
rejecting non-Good status or malformed metadata. Read/Close remain
strictly validated, and transfer/cleanup failures remain independently
visible. The follow-up also adds 21 direct-client regression cases and
updates `docs/FileSystemClient.md`, bringing this PR to 15 changed
paths. The current bounded-copy implementation is therefore
intentionally no longer identical to the frozen source.

No UaLens projects/UI, AMB or Machinery implementations, samples,
CI/native renderer changes, release changes, or generic infrastructure
files are included. The follow-up does not merge/rebase a lower or upper
stack layer.

**Current review-fix validation:** normal analyzers and
warnings-as-errors remained enabled, using the existing SDK **10.0.401**
without changing `global.json`, installing an SDK, or adding
suppressions. The final file-system and session-context selection passed
**241 tests on net10.0 and 241 tests on net48**, with **0 failed and 0
skipped** on each. Both selections include all 21 new regressions.
Before the production fix, the first 20-case regression suite had 13
failures and 7 passes, reproducing the lost cleanup, cancellation,
session-recreation and Close-discovery defects. An additional
deterministic recovery-gate race case was then added and passed on both
frameworks. Informational-severity formatting/style/analyzer checks
passed for the changed C# files.

| Review requirement | Direct regression evidence |
| --- | --- |
| Pin the managed generation through copying and cleanup |
`CopyRejectsRecreatedManagedGenerationAsync`,
`CopyRejectsRecoveryBetweenGenerationAndReadDispatchAsync` |
| Preserve a generation response that returns after cancellation |
`ManagedCopyClosesHandleReturnedAfterCancellationAsync` with immediate
and delayed responses through a real managed transport lease |
| Do not spend the Close deadline discovering metadata |
`CopyUsesDeclarationCloseWithoutDiscoveryAsync` for ordinary and managed
sessions |
| Retain valid handles before rejecting generation status/metadata |
`CopyClosesHandleBeforeRejectingGenerationStatusAsync`,
`CopyClosesHandleFromMalformedGenerationResponseAsync` |
| Preserve error semantics and strict Read/Close validation |
`CopyRejectsGenerationWithoutInventingHandleAsync`,
`CopyPreservesGenerationAndCleanupFailuresAsync`,
`CopyKeepsReadAndCloseStatusValidationAsync` |

<details>
<summary>Exact review-fix validation commands and results</summary>

Commands ran from this layer's repository root. The following variables
expand the actual executable, filters and results paths. Console logs
and TRX reports were retained in the session artifacts.

```powershell
$dotnet = 'D:\git\UA-.NETStandard_master\TestResults\ualens-528-deep-repro\sdk\dotnet.exe'
$env:DOTNET_ROOT = 'D:\git\UA-.NETStandard_master\TestResults\ualens-528-deep-repro\sdk'
$env:DOTNET_ROOT_X64 = $env:DOTNET_ROOT
$env:DOTNET_CLI_TELEMETRY_OPTOUT = '1'
$client = 'tests\Opc.Ua.Client.Tests\Opc.Ua.Client.Tests.csproj'
$filter = 'FullyQualifiedName~Opc.Ua.Client.Tests.FileSystem|FullyQualifiedName~Opc.Ua.Client.Tests.SessionContexts'
$r10 = 'C:\Users\mschier\.copilot\session-state\9b51b89f-b5ed-4ed7-a097-cca67efe7b0e\files\feedback-net10'
$r48 = 'C:\Users\mschier\.copilot\session-state\9b51b89f-b5ed-4ed7-a097-cca67efe7b0e\files\feedback-net48'

# Before modifying production code, after the new tests compiled:
& $dotnet test $client --no-restore -c Release -f net10.0 -p:CustomTestTarget=net10.0 --filter 'FullyQualifiedName~TemporaryFileTransferClientBoundedReadTests' --logger 'trx;LogFileName=bounded-copy-feedback-red.trx' --results-directory $r10 --verbosity quiet
# Expected regression failure: Passed: 7, Failed: 13, Skipped: 0.

# After the production fix, before adding the explicit queued-recovery case:
& $dotnet test $client --no-restore -c Release -f net10.0 -p:CustomTestTarget=net10.0 --filter 'FullyQualifiedName~TemporaryFileTransferClientBoundedReadTests' --logger 'trx;LogFileName=bounded-copy-feedback-green.trx' --results-directory $r10 --verbosity quiet
# Passed: 20, Failed: 0, Skipped: 0.

# Final complete affected selections, including all 21 new cases:
& $dotnet test $client --no-restore -c Release -f net48 -p:CustomTestTarget=net48 --filter $filter --logger 'trx;LogFileName=file-system-context-feedback-final.trx' --results-directory $r48 --verbosity quiet
# Passed: 241, Failed: 0, Skipped: 0.

& $dotnet test $client -c Release -f net10.0 -p:CustomTestTarget=net10.0 --filter $filter --logger 'trx;LogFileName=file-system-context-feedback-final.trx' --results-directory $r10 --verbosity quiet
# Passed: 241, Failed: 0, Skipped: 0.

$sourceFile = 'D:\git\copilot-worktrees\UA-wot-d3-final\marcschier-improved-invention\src\Opc.Ua.Client\FileSystem\TemporaryFileTransferClient.BoundedRead.cs'
$testFile = 'D:\git\copilot-worktrees\UA-wot-d3-final\marcschier-improved-invention\tests\Opc.Ua.Client.Tests\FileSystem\TemporaryFileTransferClientBoundedReadTests.cs'
$env:CustomTestTarget = 'net48'
& $dotnet format 'src\Opc.Ua.Client\Opc.Ua.Client.csproj' --include $sourceFile --no-restore --verify-no-changes --severity info --verbosity quiet
# Passed, exit 0.
$env:CustomTestTarget = 'net10.0'
& $dotnet format $client --include $testFile --no-restore --verify-no-changes --severity info --verbosity quiet
# Passed, exit 0.

git diff --check
# Passed. CRLF, final newlines and the 120-column C# limit were also checked.
```

Initial no-restore test attempts after switching frameworks exited
without executing tests and were not counted as passes. Diagnostic
`dotnet build ... --no-restore` runs reported `NETSDK1005` for the
missing framework assets; normal restore was then allowed.
Test-authoring compile errors were corrected before recording the red
run, without suppressions. Intermediate broader selections passed
240/240 on each framework before the queued-recovery case increased the
final count to 241.

</details>

**Initial-extraction validation (commit
`86ff10d3994f132f7343a929abdae209bc2e69b7`):** these results are
historical, not a claim that the broader initial selections were rerun
after the review fix. The server test assembly was built as a dependency
of the client project before its `--no-build` runs.

| Initial targeted scope | net10.0 | net48 |
| --- | --- | --- |
| Client subscriptions and file system | Passed: 546, Failed: 0,
Skipped: 0 | Passed: 542, Failed: 0, Skipped: 0 |
| Hosted roles, identity, composition, fluent APIs and builder coverage
| Passed: 164, Failed: 0, Skipped: 0 | Passed: 164, Failed: 0, Skipped:
0 |
| WebAPI WSS transport | Passed: 37, Failed: 0, Skipped: 0 | Not
supported: the repository excludes this .NET 8+ test project on net48 |
| V2 transfer and snapshot integration | Passed: 6, Failed: 0, Skipped:
0 | Passed: 6, Failed: 0, Skipped: 0 |

The first broader net48 hosting run had **9 startup timeouts and
certificate leaks** (155 passed). A single startup case reproduced the
failure. With only a shorter `NUnit.WorkDirectory`, the isolated case
and then all 164 hosting cases passed, with certificate leak tracking
enabled and no teardown leaks. This is consistent with the fixture's
documented .NET Framework PKI path-length constraint; no source or
diagnostic suppression was changed. Both failed runs and successful
reruns are recorded below.

**Deferred coverage:** the broader domain-level
`MachineryResultTransferTests*.cs` fixtures remain in Machinery layer
#4804. They were not imported or run here. This review follow-up adds
direct-client coverage for the three defects instead. The Machinery
owner has been informed that the old expectations for failed Close
discovery and zero cleanup after a returned non-Good generation response
must be reconciled when the lower fixes are propagated.

**Not run:** complete `UA.slnx` suites on net48/net10.0, a coverage
measurement, CI/CodeQL completion, native/fuzz qualifications, or
package publishing. This remains a draft pending full-stack integration
and CI evidence.

<details>
<summary>Initial-extraction validation command arguments and
outcomes</summary>

All commands ran from this layer's repository root. Variables below
expand the executable, project paths, filters and results directories
used by the actual commands; test output was also retained in
session-local log files.

```powershell
$dotnet = 'D:\git\UA-.NETStandard_master\TestResults\ualens-528-deep-repro\sdk\dotnet.exe'
$env:DOTNET_ROOT = 'D:\git\UA-.NETStandard_master\TestResults\ualens-528-deep-repro\sdk'
$env:DOTNET_ROOT_X64 = $env:DOTNET_ROOT
$env:DOTNET_CLI_TELEMETRY_OPTOUT = '1'
$r10 = 'C:\Users\mschier\.copilot\session-state\9b51b89f-b5ed-4ed7-a097-cca67efe7b0e\files\validation-net10'
$r48 = 'C:\Users\mschier\.copilot\session-state\9b51b89f-b5ed-4ed7-a097-cca67efe7b0e\files\validation-net48'
$client = 'tests\Opc.Ua.Client.Tests\Opc.Ua.Client.Tests.csproj'
$server = 'tests\Opc.Ua.Server.Tests\Opc.Ua.Server.Tests.csproj'
$webapi = 'tests\Opc.Ua.Bindings.Https.WebApi.Tests\Opc.Ua.Bindings.Https.WebApi.Tests.csproj'
$subscriptions = 'tests\Opc.Ua.Subscriptions.Tests\Opc.Ua.Subscriptions.Tests.csproj'
$clientFilter = 'FullyQualifiedName~Opc.Ua.Client.Subscriptions|FullyQualifiedName~Opc.Ua.Client.Tests.FileSystem'
$serverFilter = 'FullyQualifiedName~HostedRolePresetTests|FullyQualifiedName~IdentityHostingTests|FullyQualifiedName~ServerFluentApiHostingTests|FullyQualifiedName~ServerCompositionHostingTests|FullyQualifiedName~OpcUaServerBuilderExtensionsCoverageTests'
$subscriptionFilter = 'FullyQualifiedName~Opc.Ua.Subscriptions.Tests.TransferSubscriptionTests|FullyQualifiedName~Opc.Ua.Subscriptions.Tests.SubscriptionSnapshotTests'

& $dotnet --version
# Passed: 10.0.401.

& $dotnet test $client --no-restore -c Release -f net10.0 -p:CustomTestTarget=net10.0 --filter $clientFilter --logger 'trx;LogFileName=client-net10.trx' --results-directory $r10 --verbosity quiet
# Exit 0 but no tests executed: missing restore metadata. NOT counted as validation.

& $dotnet build $client --no-restore -c Release -f net10.0 -p:CustomTestTarget=net10.0 --verbosity quiet
# Failed: NETSDK1004, missing obj\project.assets.json. Normal restore was then allowed in test commands.

& $dotnet test $client -c Release -f net10.0 -p:CustomTestTarget=net10.0 --filter $clientFilter --logger 'trx;LogFileName=client-net10.trx' --results-directory $r10 --verbosity quiet
# Passed: 546, Failed: 0, Skipped: 0.

& $dotnet test $server --no-build --no-restore -c Release -f net10.0 -p:CustomTestTarget=net10.0 --filter $serverFilter --logger 'trx;LogFileName=server-net10.trx' --results-directory $r10 --verbosity quiet
# Passed: 164, Failed: 0, Skipped: 0.

& $dotnet test $webapi -c Release -f net10.0 -p:CustomTestTarget=net10.0 --filter 'FullyQualifiedName~WebApiWssTransportChannelTests' --logger 'trx;LogFileName=webapi-net10.trx' --results-directory $r10 --verbosity quiet
# Passed: 37, Failed: 0, Skipped: 0.

& $dotnet test $client -c Release -f net48 -p:CustomTestTarget=net48 --filter $clientFilter --logger 'trx;LogFileName=client-net48.trx' --results-directory $r48 --verbosity quiet
# Passed: 542, Failed: 0, Skipped: 0.

& $dotnet test $server --no-build --no-restore -c Release -f net48 -p:CustomTestTarget=net48 --filter $serverFilter --logger 'trx;LogFileName=server-net48.trx' --results-directory $r48 --verbosity quiet
# Failed: Passed: 155, Failed: 9, Skipped: 0; startup timeouts and 9 undisposed certificates at teardown.

& $dotnet test $subscriptions -c Release -f net48 -p:CustomTestTarget=net48 --filter $subscriptionFilter --logger 'trx;LogFileName=subscriptions-net48.trx' --results-directory $r48 --verbosity quiet
# Passed: 6, Failed: 0, Skipped: 0.

& $dotnet test $subscriptions -c Release -f net10.0 -p:CustomTestTarget=net10.0 --filter $subscriptionFilter --logger 'trx;LogFileName=subscriptions-net10.trx' --results-directory $r10 --verbosity quiet
# Passed: 6, Failed: 0, Skipped: 0.

& $dotnet msbuild $webapi -p:CustomTestTarget=net48 -p:TargetFramework=net48 -getProperty:TargetFramework,RestrictForLegacyTfm,_RestrictedToLegacyTfm,IsTestProject,EnableDefaultCompileItems
# Passed: net48 / true / true / false / false. Confirms the net48 project is an empty non-test shell.

# Each following diagnostic invocation had certificate allocation tracking enabled.
$env:OPCUA_CERTIFICATE_LEAK_TRACKING = '1'

& $dotnet test $server --no-build --no-restore -c Release -f net48 -p:CustomTestTarget=net48 --filter 'FullyQualifiedName~HostedRolePresetTests' --logger 'trx;LogFileName=hosted-role-net48.trx' --results-directory $r48 --verbosity quiet
# Passed: 10, Failed: 0, Skipped: 0.

& $dotnet test $server --no-build --no-restore -c Release -f net48 -p:CustomTestTarget=net48 --filter 'FullyQualifiedName~ServerFluentApiHostingTests.AddServerWithCustomServerStartsCustomServerAsync' --logger 'trx;LogFileName=hosting-startup-net48.trx' --results-directory $r48 --verbosity quiet
# Failed: Passed: 0, Failed: 1, Skipped: 0; 30-second startup timeout and one certificate leak.

# A fresh, dedicated short working directory was created for the following two runs.
& $dotnet test $server --no-build --no-restore -c Release -f net48 -p:CustomTestTarget=net48 --filter 'FullyQualifiedName~ServerFluentApiHostingTests.AddServerWithCustomServerStartsCustomServerAsync' --logger 'trx;LogFileName=hosting-startup-net48-shortpath.trx' --results-directory $r48 --verbosity quiet -- 'NUnit.WorkDirectory=D:\Temp\opc-l2-6caf4556'
# Passed: 1, Failed: 0, Skipped: 0; no certificate leak.

& $dotnet test $server --no-build --no-restore -c Release -f net48 -p:CustomTestTarget=net48 --filter $serverFilter --logger 'trx;LogFileName=server-net48-shortpath.trx' --results-directory $r48 --verbosity quiet -- 'NUnit.WorkDirectory=D:\Temp\opc-l2-6caf4556'
# Passed: 164, Failed: 0, Skipped: 0; no certificate leak or assembly-level teardown error.
# The dedicated short working directory and its generated PKI contents were removed afterward.
```

TRX inspection confirmed all initially added client (3), WSS (4), and
hosted-role (10) regression cases executed on supported frameworks. The
initial 13-path set and source blobs were verified before the extraction
commit. A separate mistyped SDK availability probe
(`uals-528-deep-repro` instead of `ualens-528-deep-repro`) failed before
launching any process; all actual builds/tests used the correct verified
SDK above.

</details>

## Related Issues

Related to #4461; extracted from source PR #4462. Neither is closed by
this PR. Prerequisite: #4801, the core/types layer.

_If this is a relatively large or complex change, a design must have
been discussed in the related tracking issue and signed off (which
becomes the Architectural Decision Record (ADR))._

## Checklist

_Put an `x` in the boxes that apply. You can complete these step by step
after opening the PR._

- [ ] 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.
- [x] I have addressed **all** PR feedback received.
This commit is contained in:
Marc Schier
2026-10-15 19:29:42 +02:00
committed by GitHub
parent 0374e19121
commit e78d36ce4b
15 changed files with 1774 additions and 20 deletions
@@ -35,6 +35,7 @@ using System.Net.Http;
using System.Net.WebSockets;
using System.Reflection;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Threading;
using System.Threading.Tasks;
@@ -68,7 +69,7 @@ namespace Opc.Ua.Bindings.Https.WebApi.Tests
/// <c>http://127.0.0.1:0</c> with <see cref="WebSocketOptions"/>
/// enabled — plain <c>ws://</c> sidesteps the TLS / certificate
/// management overhead unit tests don't need. The same wire
/// envelope (<c>{TypeId, Body}</c> standard OPC UA JSON message)
/// envelope (<c>UaTypeId</c> with inline service fields)
/// is reused, so the channel exercises the same encode/decode path
/// it does against the real <c>HttpsTransportListener.AcceptWebSocketOpenApiAsync</c>.
/// </remarks>
@@ -84,6 +85,8 @@ namespace Opc.Ua.Bindings.Https.WebApi.Tests
private ServiceMessageContext? m_messageContext;
private string? m_lastNegotiatedSubProtocol;
private IServiceRequest? m_lastRequest;
private ByteString m_lastResponseBytes;
private TaskCompletionSource? m_handshakeReceived;
private Func<IServiceRequest, IServiceResponse>? m_responder;
private Func<WebSocket, CancellationToken, Task>? m_socketScript;
@@ -101,6 +104,8 @@ namespace Opc.Ua.Bindings.Https.WebApi.Tests
.Commit();
m_lastNegotiatedSubProtocol = null;
m_lastRequest = null;
m_lastResponseBytes = default;
m_handshakeReceived = null;
m_responder = DefaultResponder;
m_socketScript = null;
@@ -157,6 +162,12 @@ namespace Opc.Ua.Bindings.Https.WebApi.Tests
Assert.That(response, Is.InstanceOf<ReadResponse>());
Assert.That(m_lastRequest, Is.InstanceOf<ReadRequest>());
Assert.That(response.ResponseHeader.RequestHandle, Is.EqualTo(4242u));
using var envelope = JsonDocument.Parse(m_lastResponseBytes.Memory);
Assert.That(envelope.RootElement.GetProperty("UaTypeId").GetString(), Is.EqualTo("i=632"));
Assert.That(envelope.RootElement.GetProperty("ResponseHeader").GetProperty("RequestHandle").GetUInt32(),
Is.EqualTo(m_lastRequest!.RequestHeader.RequestHandle).And.Not.EqualTo(4242u));
Assert.That(envelope.RootElement.TryGetProperty("UaBody", out _), Is.False);
Assert.That(request.RequestHeader.RequestHandle, Is.EqualTo(4242u));
}
[Test]
@@ -1251,6 +1262,59 @@ namespace Opc.Ua.Bindings.Https.WebApi.Tests
AssertRequestUnchanged(request, snapshot, originalHeader);
}
[TestCase(false)]
[TestCase(true)]
public async Task OpeningHandshakeHonorsDeadlineAndCallerCancellationAsync(bool cancel)
{
var time = new FakeTimeProvider();
m_handshakeReceived = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
using var cancellation = new CancellationTokenSource();
using var channel = new WebApiWssTransportChannel(new TelemetryStub(), timeProvider: time);
EndpointConfiguration configuration = EndpointConfiguration.Create();
configuration.OperationTimeout = 100;
TransportChannelSettings settings = CreateChannelSettings(configuration);
Task opening = channel.OpenAsync(m_baseUri, settings, cancellation.Token).AsTask();
await m_handshakeReceived.Task.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false);
time.Advance(TimeSpan.FromMilliseconds(99));
Assert.That(opening.IsCompleted, Is.False);
if (cancel)
{
await cancellation.CancelAsync().ConfigureAwait(false);
await Assert.ThatAsync(() => opening.WaitAsync(TimeSpan.FromSeconds(5)),
Throws.InstanceOf<OperationCanceledException>()).ConfigureAwait(false);
}
else
{
time.Advance(TimeSpan.FromMilliseconds(1));
await Assert.ThatAsync(() => opening.WaitAsync(TimeSpan.FromSeconds(5)),
Throws.TypeOf<ServiceResultException>()
.With.Property(nameof(ServiceResultException.StatusCode))
.EqualTo(StatusCodes.BadRequestTimeout)).ConfigureAwait(false);
}
m_handshakeReceived = null;
await channel.OpenAsync(m_baseUri, settings, CancellationToken.None).AsTask()
.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false);
IServiceResponse response = await channel.SendRequestAsync(
new ReadRequest { RequestHeader = new RequestHeader { RequestHandle = 42 } }).AsTask()
.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false);
Assert.That(response, Is.TypeOf<ReadResponse>());
Assert.That(response.ResponseHeader.RequestHandle, Is.EqualTo(42));
}
[Test]
public Task SilentServerRequestUsesOperationTimeoutAsync()
{
return AssertSilentServerRequestTerminationAsync(cancel: false);
}
[Test]
public Task SilentServerRequestPreservesCallerCancellationAsync()
{
return AssertSilentServerRequestTerminationAsync(cancel: true);
}
private static void AssertRequestUnchanged(
IServiceRequest request,
IServiceRequest snapshot,
@@ -1322,12 +1386,80 @@ namespace Opc.Ua.Bindings.Https.WebApi.Tests
return channel;
}
private async Task AssertSilentServerRequestTerminationAsync(bool cancel)
{
var received = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
m_socketScript = async (socket, ct) =>
{
_ = await ReceiveServiceRequestAsync(socket, ct).ConfigureAwait(false);
received.TrySetResult();
IServiceRequest retry = await ReceiveServiceRequestAsync(socket, ct).ConfigureAwait(false);
await SendResponseAsync(socket, DefaultResponder(retry), ct).ConfigureAwait(false);
await release.Task.WaitAsync(ct).ConfigureAwait(false);
};
var time = new FakeTimeProvider();
using WebApiWssTransportChannel channel = await OpenChannelAsync(timeProvider: time)
.ConfigureAwait(false);
using var cancellation = new CancellationTokenSource();
channel.OperationTimeout = 100;
try
{
Task<IServiceResponse> request = channel.SendRequestAsync(new GetEndpointsRequest
{
RequestHeader = new RequestHeader { RequestHandle = 24 },
EndpointUrl = m_baseUri.AbsoluteUri
}, cancellation.Token).AsTask();
await received.Task.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false);
time.Advance(TimeSpan.FromMilliseconds(99));
Assert.That(request.IsCompleted, Is.False);
if (cancel)
{
await cancellation.CancelAsync().ConfigureAwait(false);
await Assert.ThatAsync(() => request.WaitAsync(TimeSpan.FromSeconds(5)),
Throws.InstanceOf<OperationCanceledException>()).ConfigureAwait(false);
}
else
{
time.Advance(TimeSpan.FromMilliseconds(1));
await Assert.ThatAsync(() => request.WaitAsync(TimeSpan.FromSeconds(5)),
Throws.TypeOf<ServiceResultException>()
.With.Property(nameof(ServiceResultException.StatusCode))
.EqualTo(StatusCodes.BadRequestTimeout)
.And.Message.Contains("100 ms")).ConfigureAwait(false);
}
IServiceResponse response = await channel.SendRequestAsync(new GetEndpointsRequest
{
RequestHeader = new RequestHeader { RequestHandle = 25 },
EndpointUrl = m_baseUri.AbsoluteUri
}).AsTask().WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false);
Assert.That(response, Is.TypeOf<GetEndpointsResponse>());
Assert.That(response.ResponseHeader.RequestHandle, Is.EqualTo(25));
}
finally
{
release.TrySetResult();
await channel.CloseAsync(CancellationToken.None).AsTask().WaitAsync(TimeSpan.FromSeconds(5))
.ConfigureAwait(false);
}
}
private async Task<WebApiWssTransportChannel> OpenChannelAsync(
WebApiClientOptions? options = null,
EndpointConfiguration? configuration = null)
EndpointConfiguration? configuration = null,
TimeProvider? timeProvider = null)
{
var channel = new WebApiWssTransportChannel(new TelemetryStub(), options);
var settings = new TransportChannelSettings
var channel = new WebApiWssTransportChannel(new TelemetryStub(), options, timeProvider);
await channel.OpenAsync(m_baseUri, CreateChannelSettings(configuration), CancellationToken.None)
.ConfigureAwait(false);
return channel;
}
private TransportChannelSettings CreateChannelSettings(EndpointConfiguration? configuration)
{
return new TransportChannelSettings
{
Description = new EndpointDescription
{
@@ -1342,9 +1474,6 @@ namespace Opc.Ua.Bindings.Https.WebApi.Tests
Factory = m_messageContext!.Factory,
NamespaceUris = new NamespaceTable()
};
await channel.OpenAsync(m_baseUri, settings, CancellationToken.None)
.ConfigureAwait(false);
return channel;
}
private async Task HandleWebSocketAsync(HttpContext context)
@@ -1366,6 +1495,13 @@ namespace Opc.Ua.Bindings.Https.WebApi.Tests
}
m_lastNegotiatedSubProtocol = subProtocol;
if (m_handshakeReceived is TaskCompletionSource received)
{
received.TrySetResult();
await Task.Delay(Timeout.InfiniteTimeSpan, context.RequestAborted).ConfigureAwait(false);
return;
}
using WebSocket ws = await context.WebSockets
.AcceptWebSocketAsync(subProtocol)
.ConfigureAwait(false);
@@ -1456,7 +1592,9 @@ namespace Opc.Ua.Bindings.Https.WebApi.Tests
private Task SendResponseAsync(WebSocket socket, IServiceResponse response, CancellationToken ct)
{
return SendResponseBytesAsync(socket, EncodeResponse(response), ct);
byte[] responseBytes = EncodeResponse(response);
m_lastResponseBytes = ByteString.From(responseBytes);
return SendResponseBytesAsync(socket, responseBytes, ct);
}
private static async Task SendResponseBytesAsync(WebSocket socket, byte[] responseBytes, CancellationToken ct)
@@ -0,0 +1,568 @@
/* ========================================================================
* 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.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Moq;
using NUnit.Framework;
using Opc.Ua.Client.FileSystem;
using Opc.Ua.Client.Tests.SessionContexts;
using Opc.Ua.Client.Tests.Stack.Client.Fakes;
namespace Opc.Ua.Client.Tests.FileSystem
{
/// <summary>
/// Verifies bounded generated-file copying through real session dispatch and managed transport leases.
/// </summary>
[TestFixture]
[Parallelizable(ParallelScope.All)]
public sealed class TemporaryFileTransferClientBoundedReadTests
{
/// <summary>
/// A recreated session cannot receive an old handle even when it reuses the public session identifier.
/// </summary>
[TestCase(false, false)]
[TestCase(false, true)]
[TestCase(true, false)]
[TestCase(true, true)]
public async Task CopyRejectsRecreatedManagedGenerationAsync(bool reuseToken, bool duringReadiness)
{
await using var fixture = new SessionRequestContextTestContext();
var script = new TransferScript();
fixture.Handler = script.DispatchAsync;
NodeId originalSessionId = fixture.InnerSession.SessionId;
NodeId originalToken = fixture.InnerSession.SaveSessionConfiguration().AuthenticationToken;
ValueTask RecreateAsync(CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
fixture.InnerSession.SessionCreated(originalSessionId,
reuseToken ? originalToken : NodeId.Parse("s=replacement-token"));
return default;
}
if (duringReadiness)
{
script.GenerationResponse.Results[0].OutputArguments =
[Variant.From(s_file), Variant.From(kHandle), Variant.From(s_stateMachine)];
script.OnStateRead = RecreateAsync;
}
else
{
script.OnGenerate = RecreateAsync;
}
script.Chunks.Enqueue(ByteString.Empty);
var client = new TemporaryFileTransferClient(fixture.ManagedSession, s_transfer);
using var destination = new MemoryStream();
AggregateException failure = Assert.ThrowsAsync<AggregateException>(
async () => await client.CopyToAsync(default, destination, 3).ConfigureAwait(false))!;
Assert.That(failure.InnerExceptions, Has.Count.EqualTo(2));
Assert.That(failure.InnerExceptions, Has.All.TypeOf<ServiceResultException>()
.With.Property(nameof(ServiceResultException.StatusCode)).EqualTo(StatusCodes.BadSessionIdInvalid));
Assert.That(script.Methods, Is.EqualTo(new[] { s_generateMethod }));
Assert.That(fixture.Requests, Has.Count.EqualTo(duringReadiness ? 4 : 2));
Assert.That(fixture.Requests.Select(request => request.RequestHeader.AuthenticationToken),
Is.All.EqualTo(originalToken));
Assert.That(destination.Length, Is.Zero);
Assert.That(fixture.InnerSession.Connected, Is.True);
fixture.Channel.Verify(channel => channel.CloseAsync(It.IsAny<CancellationToken>()), Times.Never);
fixture.Channel.Verify(channel => channel.Dispose(), Times.Never);
}
/// <summary>
/// Recovery queued behind generation is rechecked at the next dispatch, not just before entering the gate.
/// </summary>
[Test]
public async Task CopyRejectsRecoveryBetweenGenerationAndReadDispatchAsync()
{
await using var fixture = new SessionRequestContextTestContext();
var entered = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var release = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
async Task RecreateBehindDispatchAsync()
{
using (await fixture.ServiceGate.WriterLockAsync().ConfigureAwait(false))
{
entered.TrySetResult(true);
await release.Task.ConfigureAwait(false);
fixture.Recreate();
}
}
Task? recovery = null;
var script = new TransferScript
{
OnGenerate = _ =>
{
recovery = RecreateBehindDispatchAsync();
return default;
}
};
script.Chunks.Enqueue(ByteString.Empty);
fixture.Handler = script.DispatchAsync;
var client = new TemporaryFileTransferClient(fixture.ManagedSession, s_transfer);
using var destination = new MemoryStream();
Task<long> copying = client.CopyToAsync(default, destination, 3).AsTask();
try
{
await entered.Task.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false);
Assert.That(copying.IsCompleted, Is.False);
Assert.That(fixture.Requests, Has.Count.EqualTo(2));
release.TrySetResult(true);
AggregateException failure = Assert.ThrowsAsync<AggregateException>(
async () => await copying.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false))!;
Assert.That(failure.InnerExceptions, Has.Count.EqualTo(2));
Assert.That(failure.InnerExceptions, Has.All.TypeOf<ServiceResultException>()
.With.Property(nameof(ServiceResultException.StatusCode)).EqualTo(StatusCodes.BadSessionIdInvalid));
Assert.That(script.Methods, Is.EqualTo(new[] { s_generateMethod }));
Assert.That(fixture.Requests, Has.Count.EqualTo(2));
Assert.That(destination.Length, Is.Zero);
}
finally
{
release.TrySetResult(true);
if (recovery != null)
{
await recovery.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false);
}
}
}
/// <summary>
/// Cancellation cannot discard a generated handle returned through a managed transport lease.
/// </summary>
[TestCase(false)]
[TestCase(true)]
public async Task ManagedCopyClosesHandleReturnedAfterCancellationAsync(bool delayedResponse)
{
await using var channels = new SessionChannelHarness();
await using Client.ManagedSession owner = await Client.ManagedSession.CreateAsync(
new ManagedSessionOptions { Endpoint = SessionChannelHarness.CreateEndpoint() },
channels.Configuration,
new DefaultSessionFactory(channels.Telemetry),
telemetry: channels.Telemetry,
channelManager: channels.Manager,
updateBeforeConnect: false).WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false);
Assert.That(owner.TransportChannel, Is.InstanceOf<IManagedTransportChannel>());
using var cancellation = new CancellationTokenSource();
var entered = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var release = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var script = new TransferScript
{
OnGenerate = async ct =>
{
Assert.That(ct, Is.EqualTo(cancellation.Token));
entered.TrySetResult(true);
if (delayedResponse)
{
await release.Task.ConfigureAwait(false);
}
else
{
await cancellation.CancelAsync().ConfigureAwait(false);
}
}
};
ScriptedChannel transport = channels.CreatedChannels[0];
transport.RequestHandler = (request, ct) =>
request is CallRequest or TranslateBrowsePathsToNodeIdsRequest
? script.DispatchAsync(request, ct)
: new ValueTask<IServiceResponse>(transport.CreateResponse(request));
var client = new TemporaryFileTransferClient(owner, s_transfer);
using var destination = new MemoryStream();
Task<long> copying = client.CopyToAsync(default, destination, 3, cancellation.Token).AsTask();
try
{
await entered.Task.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false);
if (delayedResponse)
{
await cancellation.CancelAsync().ConfigureAwait(false);
release.TrySetResult(true);
}
await Assert.ThatAsync(
() => copying.WaitAsync(TimeSpan.FromSeconds(10)),
Throws.InstanceOf<OperationCanceledException>()).ConfigureAwait(false);
Assert.That(script.Methods, Is.EqualTo(new[] { s_generateMethod, MethodIds.FileType_Close }));
Assert.That(script.CloseToken.CanBeCanceled, Is.True);
Assert.That(script.CloseToken.IsCancellationRequested, Is.False);
Assert.That(script.CloseToken, Is.Not.EqualTo(cancellation.Token));
Assert.That(script.CloseDiscoveries, Is.Zero);
Assert.That(destination.Length, Is.Zero);
Assert.That(destination.CanWrite, Is.True);
Assert.That(owner.Connected, Is.True);
Assert.That(transport.CloseCount, Is.Zero);
Assert.That(transport.DisposeCount, Is.Zero);
}
finally
{
release.TrySetResult(true);
}
}
/// <summary>
/// Cleanup spends its independent deadline on the declaration Close call, not optional metadata discovery.
/// </summary>
[TestCase(false)]
[TestCase(true)]
public async Task CopyUsesDeclarationCloseWithoutDiscoveryAsync(bool managed)
{
await using var fixture = new SessionRequestContextTestContext();
var script = new TransferScript { RejectCloseDiscovery = true };
script.Chunks.Enqueue(ByteString.From([1, 2, 3]));
script.Chunks.Enqueue(ByteString.Empty);
fixture.Handler = script.DispatchAsync;
var client = new TemporaryFileTransferClient(
managed ? fixture.ManagedSession : fixture.InnerSession, s_transfer);
using var destination = new MemoryStream();
long copied = await client.CopyToAsync(default, destination, 3).ConfigureAwait(false);
Assert.That(copied, Is.EqualTo(3));
Assert.That(destination.ToArray(), Is.EqualTo(new byte[] { 1, 2, 3 }));
Assert.That(script.Methods, Is.EqualTo(
new[] { s_generateMethod, s_readMethod, s_readMethod, MethodIds.FileType_Close }));
Assert.That(script.CloseDiscoveries, Is.Zero);
Assert.That(script.CloseToken.CanBeCanceled, Is.True);
Assert.That(script.CloseToken.IsCancellationRequested, Is.False);
Assert.That(destination.CanWrite, Is.True);
Assert.That(fixture.InnerSession.Connected, Is.True);
fixture.Channel.Verify(channel => channel.CloseAsync(It.IsAny<CancellationToken>()), Times.Never);
fixture.Channel.Verify(channel => channel.Dispose(), Times.Never);
}
/// <summary>
/// A non-Good generation result cannot hide a valid allocated handle from cleanup.
/// </summary>
[TestCase(false, false)]
[TestCase(false, true)]
[TestCase(true, false)]
public async Task CopyClosesHandleBeforeRejectingGenerationStatusAsync(bool serviceStatus, bool badStatus)
{
await using var fixture = new SessionRequestContextTestContext();
var script = new TransferScript();
StatusCode status = badStatus ? StatusCodes.BadUserAccessDenied : StatusCodes.Uncertain;
SetStatus(script.GenerationResponse, serviceStatus, status);
fixture.Handler = script.DispatchAsync;
var client = new TemporaryFileTransferClient(fixture.InnerSession, s_transfer);
using var destination = new MemoryStream();
ServiceResultException failure = Assert.ThrowsAsync<ServiceResultException>(
async () => await client.CopyToAsync(default, destination, 3).ConfigureAwait(false))!;
Assert.That(failure.StatusCode, Is.EqualTo(status));
Assert.That(script.Methods, Is.EqualTo(new[] { s_generateMethod, MethodIds.FileType_Close }));
Assert.That(destination.Length, Is.Zero);
}
/// <summary>
/// Response-count and diagnostic errors are rejected only after retaining the returned file handle.
/// </summary>
[TestCase(false)]
[TestCase(true)]
public async Task CopyClosesHandleFromMalformedGenerationResponseAsync(bool extraResult)
{
await using var fixture = new SessionRequestContextTestContext();
var script = new TransferScript();
if (extraResult)
{
script.GenerationResponse.Results = [script.GenerationResponse.Results[0], new CallMethodResult()];
}
else
{
script.GenerationResponse.DiagnosticInfos = [new DiagnosticInfo(), new DiagnosticInfo()];
}
fixture.Handler = script.DispatchAsync;
var client = new TemporaryFileTransferClient(fixture.InnerSession, s_transfer);
using var destination = new MemoryStream();
ServiceResultException failure = Assert.ThrowsAsync<ServiceResultException>(
async () => await client.CopyToAsync(default, destination, 3).ConfigureAwait(false))!;
Assert.That(failure.StatusCode, Is.EqualTo(StatusCodes.BadUnexpectedError));
Assert.That(script.Methods, Is.EqualTo(new[] { s_generateMethod, MethodIds.FileType_Close }));
Assert.That(destination.Length, Is.Zero);
}
/// <summary>
/// A rejected generation without a complete handle cannot trigger an invented Close.
/// </summary>
[TestCase(false)]
[TestCase(true)]
public async Task CopyRejectsGenerationWithoutInventingHandleAsync(bool uncertain)
{
await using var fixture = new SessionRequestContextTestContext();
var script = new TransferScript();
script.GenerationResponse.Results[0].StatusCode = uncertain ? StatusCodes.Uncertain : StatusCodes.Good;
script.GenerationResponse.Results[0].OutputArguments = [Variant.From(s_file)];
fixture.Handler = script.DispatchAsync;
var client = new TemporaryFileTransferClient(fixture.InnerSession, s_transfer);
using var destination = new MemoryStream();
ServiceResultException failure = Assert.ThrowsAsync<ServiceResultException>(
async () => await client.CopyToAsync(default, destination, 3).ConfigureAwait(false))!;
Assert.That(failure.StatusCode,
Is.EqualTo(uncertain ? StatusCodes.Uncertain : StatusCodes.BadUnexpectedError));
Assert.That(script.Methods, Is.EqualTo(new[] { s_generateMethod }));
Assert.That(destination.Length, Is.Zero);
}
/// <summary>
/// Generation rejection and cleanup failure remain independently visible in their original order.
/// </summary>
[Test]
public async Task CopyPreservesGenerationAndCleanupFailuresAsync()
{
await using var fixture = new SessionRequestContextTestContext();
var script = new TransferScript();
script.GenerationResponse.Results[0].StatusCode = StatusCodes.Uncertain;
script.CloseResponse.Results[0].StatusCode = StatusCodes.BadUserAccessDenied;
fixture.Handler = script.DispatchAsync;
var client = new TemporaryFileTransferClient(fixture.InnerSession, s_transfer);
using var destination = new MemoryStream();
AggregateException failure = Assert.ThrowsAsync<AggregateException>(
async () => await client.CopyToAsync(default, destination, 3).ConfigureAwait(false))!;
Assert.That(failure.InnerExceptions, Has.Count.EqualTo(2));
Assert.That(failure.InnerExceptions[0], Is.TypeOf<ServiceResultException>()
.With.Property(nameof(ServiceResultException.StatusCode)).EqualTo(StatusCodes.Uncertain));
Assert.That(failure.InnerExceptions[1], Is.TypeOf<ServiceResultException>()
.With.Property(nameof(ServiceResultException.StatusCode)).EqualTo(StatusCodes.BadUserAccessDenied));
Assert.That(script.Methods, Is.EqualTo(new[] { s_generateMethod, MethodIds.FileType_Close }));
}
/// <summary>
/// Delaying generation validation does not weaken service or method validation for Read and Close.
/// </summary>
[TestCase(false, false)]
[TestCase(false, true)]
[TestCase(true, false)]
[TestCase(true, true)]
public async Task CopyKeepsReadAndCloseStatusValidationAsync(bool closeFails, bool serviceStatus)
{
await using var fixture = new SessionRequestContextTestContext();
var script = new TransferScript();
script.Chunks.Enqueue(closeFails ? ByteString.Empty : ByteString.From([1]));
SetStatus(closeFails ? script.CloseResponse : script.ReadResponse, serviceStatus, StatusCodes.Uncertain);
fixture.Handler = script.DispatchAsync;
var client = new TemporaryFileTransferClient(fixture.InnerSession, s_transfer);
using var destination = new MemoryStream();
ServiceResultException failure = Assert.ThrowsAsync<ServiceResultException>(
async () => await client.CopyToAsync(default, destination, 3).ConfigureAwait(false))!;
Assert.That(failure.StatusCode, Is.EqualTo(StatusCodes.Uncertain));
Assert.That(script.Methods, Is.EqualTo(new[] { s_generateMethod, s_readMethod, MethodIds.FileType_Close }));
Assert.That(destination.Length, Is.Zero);
}
/// <summary>
/// Assigns the selected service or method status without altering the returned output arguments.
/// </summary>
private static void SetStatus(CallResponse response, bool serviceStatus, StatusCode status)
{
if (serviceStatus)
{
response.ResponseHeader.ServiceResult = status;
}
else
{
response.Results[0].StatusCode = status;
}
}
/// <summary>
/// Provides deterministic temporary-file service responses while recording actual method dispatches.
/// </summary>
private sealed class TransferScript
{
/// <summary>
/// Gets the generation response, whose metadata tests may corrupt independently of its handle.
/// </summary>
public CallResponse GenerationResponse { get; } = CreateResponse(
[Variant.From(s_file), Variant.From(kHandle), Variant.From(NodeId.Null)]);
/// <summary>
/// Gets the Read response, with output populated from the configured chunks.
/// </summary>
public CallResponse ReadResponse { get; } = CreateResponse([]);
/// <summary>
/// Gets the Close response.
/// </summary>
public CallResponse CloseResponse { get; } = CreateResponse([]);
/// <summary>
/// Gets the payload chunks; unexpected Read calls fail when none remain.
/// </summary>
public Queue<ByteString> Chunks { get; } = new();
/// <summary>
/// Gets the methods that reached the transport.
/// </summary>
public ArrayOf<NodeId> Methods => m_methods.ToArrayOf();
/// <summary>
/// Gets the number of unnecessary Close-metadata lookups.
/// </summary>
public int CloseDiscoveries { get; private set; }
/// <summary>
/// Gets the cancellation token actually delivered to Close.
/// </summary>
public CancellationToken CloseToken { get; private set; }
/// <summary>
/// Gets or sets whether optional Close metadata is unavailable.
/// </summary>
public bool RejectCloseDiscovery { get; init; }
/// <summary>
/// Gets or sets the action between generating a handle and returning its response.
/// </summary>
public Func<CancellationToken, ValueTask>? OnGenerate { get; set; }
/// <summary>
/// Gets or sets the action before a generation-readiness response completes.
/// </summary>
public Func<CancellationToken, ValueTask>? OnStateRead { get; set; }
/// <summary>
/// Executes only the expected temporary-file protocol operations.
/// </summary>
public async ValueTask<IServiceResponse> DispatchAsync(IServiceRequest request, CancellationToken ct)
{
if (request is TranslateBrowsePathsToNodeIdsRequest translate)
{
Assert.That(translate.BrowsePaths.Count, Is.EqualTo(1));
string? name = translate.BrowsePaths[0].RelativePath.Elements[0].TargetName.Name;
if (name == BrowseNames.Close)
{
CloseDiscoveries++;
if (RejectCloseDiscovery)
{
throw new ServiceResultException(StatusCodes.BadTimeout, "Close metadata is unavailable.");
}
}
NodeId target = name switch
{
BrowseNames.GenerateFileForRead => s_generateMethod,
BrowseNames.Read => s_readMethod,
BrowseNames.Close => MethodIds.FileType_Close,
BrowseNames.CurrentState => s_stateId,
_ => throw new AssertionException("Unexpected path: " + name)
};
return new TranslateBrowsePathsToNodeIdsResponse
{
ResponseHeader = new ResponseHeader(),
Results =
[
new BrowsePathResult
{
Targets =
[
new BrowsePathTarget { TargetId = target, RemainingPathIndex = uint.MaxValue }
]
}
]
};
}
if (request is ReadRequest read)
{
Assert.That(read.NodesToRead.Count, Is.EqualTo(1));
Assert.That(read.NodesToRead[0].NodeId, Is.EqualTo(s_stateId));
if (OnStateRead != null)
{
await OnStateRead(ct).ConfigureAwait(false);
}
return new ReadResponse
{
ResponseHeader = new ResponseHeader(),
Results = [new DataValue(Variant.From(ObjectIds.FileTransferStateMachineType_ReadTransfer))]
};
}
Assert.That(request, Is.TypeOf<CallRequest>());
var call = (CallRequest)request;
Assert.That(call.MethodsToCall.Count, Is.EqualTo(1));
CallMethodRequest method = call.MethodsToCall[0];
m_methods.Add(method.MethodId);
if (method.MethodId == s_generateMethod)
{
Assert.That(method.ObjectId, Is.EqualTo(s_transfer));
if (OnGenerate != null)
{
await OnGenerate(ct).ConfigureAwait(false);
}
return GenerationResponse;
}
Assert.That(method.ObjectId, Is.EqualTo(s_file));
Assert.That(method.InputArguments[0].TryGetValue(out uint handle), Is.True);
Assert.That(handle, Is.EqualTo(kHandle));
if (method.MethodId == s_readMethod)
{
Assert.That(method.InputArguments.Count, Is.EqualTo(2));
ReadResponse.Results[0].OutputArguments = [Variant.From(Chunks.Dequeue())];
return ReadResponse;
}
Assert.That(method.MethodId, Is.EqualTo(MethodIds.FileType_Close));
Assert.That(method.InputArguments.Count, Is.EqualTo(1));
CloseToken = ct;
return CloseResponse;
}
/// <summary>
/// Creates one successful method response.
/// </summary>
private static CallResponse CreateResponse(ArrayOf<Variant> outputs)
{
return new CallResponse
{
ResponseHeader = new ResponseHeader(),
Results = [new CallMethodResult { OutputArguments = outputs }]
};
}
private readonly List<NodeId> m_methods = [];
}
private const uint kHandle = 7;
private static readonly NodeId s_transfer = new(1000u);
private static readonly NodeId s_file = new(1001u);
private static readonly NodeId s_generateMethod = new(1002u);
private static readonly NodeId s_readMethod = new(1003u);
private static readonly NodeId s_stateMachine = new(1004u);
private static readonly NodeId s_stateId = new(1005u);
}
}
@@ -31,9 +31,12 @@
// making CA2000 noisy without a real leak risk. Disabled file-level for the suite.
#pragma warning disable CA2000
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Moq;
using NUnit.Framework;
using Opc.Ua.Client.Subscriptions.Fakes;
using Opc.Ua.Tests;
@@ -95,6 +98,141 @@ namespace Opc.Ua.Client.Subscriptions.MonitoredItems
Assert.That(((IMonitoredItemApplyState)sut).HasPendingChanges, Is.False);
}
/// <summary>
/// Loaded handles require a modify response for revised parameters, not another create or invented defaults.
/// </summary>
[Test]
public async Task LoadedItemRefreshesRevisedParametersOnceWithoutRecreationAsync()
{
m_options.Configure(options => options with
{
StartNodeId = new NodeId("loaded", 2),
MonitoringMode = MonitoringMode.Reporting,
SamplingInterval = TimeSpan.FromMilliseconds(20),
QueueSize = 17
});
var item = new TestMonitoredItem(m_context, m_options, m_telemetry.CreateLogger("MonitoredItem"));
await using (item.ConfigureAwait(false))
{
item.ApplyLoadState(new MonitoredItemLoadState("loaded", m_options, 42, 7, []));
Assert.That(item.TryGetPendingChange(out _), Is.False);
item.SetTransferResult(42, 9);
Assert.That(item.TryGetPendingChange(out MonitoredItem.Change? refresh), Is.True);
Assert.That(refresh!.ForceRecreate, Is.False);
Assert.That(refresh.MonitoringModeChange, Is.Null);
MonitoredItemModifyRequest request = refresh.BindModifyRequest()!;
Assert.That(request.MonitoredItemId, Is.EqualTo(9));
Assert.That(request.RequestedParameters.ClientHandle, Is.EqualTo(42));
Assert.That(request.RequestedParameters.SamplingInterval, Is.EqualTo(20));
Assert.That(request.RequestedParameters.QueueSize, Is.EqualTo(17));
refresh.SetModifyResult(request, new MonitoredItemModifyResult
{
StatusCode = StatusCodes.Good,
RevisedSamplingInterval = 1000,
RevisedQueueSize = 19
}, 0, [], new ResponseHeader());
Assert.That(item.CurrentSamplingInterval, Is.EqualTo(TimeSpan.FromSeconds(1)));
Assert.That(item.CurrentQueueSize, Is.EqualTo(19));
Assert.That(item.CurrentMonitoringMode, Is.EqualTo(MonitoringMode.Reporting));
Assert.That(item.ServerId, Is.EqualTo(9));
Assert.That(item.TryGetPendingChange(out _), Is.False);
item.SetTransferResult(42, 9);
Assert.That(item.TryGetPendingChange(out _), Is.False);
}
}
/// <summary>
/// An options callback between refresh capture and enqueue cannot restore stale monitoring parameters.
/// </summary>
[Test]
public async Task LoadedItemRefreshPreservesOptionsChangedBeforeEnqueueAsync()
{
var initialFilter = new Mock<DataChangeFilter>();
initialFilter.SetupGet(filter => filter.TypeId).Returns(DataTypeIds.DataChangeFilter);
var updatedFilter = new DataChangeFilter { Trigger = DataChangeTrigger.StatusValueTimestamp };
var captured = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
using var resume = new ManualResetEventSlim();
using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(10));
await using TestMonitoredItem item = CreateLoadedItem(initialFilter.Object);
int captures = 0;
initialFilter.SetupGet(filter => filter.TypeId).Returns(() =>
{
if (Interlocked.Increment(ref captures) == 1)
{
captured.TrySetResult(true);
// The synchronous filter getter runs after options capture but before enqueue.
resume.Wait(deadline.Token);
}
return DataTypeIds.DataChangeFilter;
});
Task transfer = Task.Run(() => item.SetTransferResult(42, 9), deadline.Token);
ArrayOf<MonitoredItemModifyRequest> requests;
try
{
await captured.Task.WaitAsync(deadline.Token).ConfigureAwait(false);
UpdateMonitoringParameters(updatedFilter);
Assert.That(item.TryGetPendingChange(out MonitoredItem.Change? optionsChange), Is.True);
Assert.That(optionsChange!.Options, Is.SameAs(m_options.CurrentValue));
resume.Set();
await transfer.WaitAsync(deadline.Token).ConfigureAwait(false);
requests = ApplyPendingModifications(item);
}
finally
{
resume.Set();
await transfer.WaitAsync(deadline.Token).ConfigureAwait(false);
}
Assert.That(captures, Is.EqualTo(1));
Assert.That(requests.Count, Is.EqualTo(2));
foreach (MonitoredItemModifyRequest request in requests)
{
AssertUpdatedMonitoringParameters(request, updatedFilter);
}
Assert.That(item.CurrentSamplingInterval, Is.EqualTo(TimeSpan.FromMilliseconds(250)));
Assert.That(item.CurrentQueueSize, Is.EqualTo(31));
Assert.That(m_options.CurrentValue.SamplingInterval, Is.EqualTo(item.CurrentSamplingInterval));
Assert.That(m_options.CurrentValue.QueueSize, Is.EqualTo(item.CurrentQueueSize));
Assert.That(item.CurrentMonitoringMode, Is.EqualTo(MonitoringMode.Reporting));
Assert.That(item.ServerId, Is.EqualTo(9));
Assert.That(item.HasPendingChanges, Is.False);
item.SetTransferResult(42, 9);
Assert.That(item.HasPendingChanges, Is.False);
}
/// <summary>
/// Sequential options changes on either side of transfer retain the latest requested settings.
/// </summary>
[TestCase(false)]
[TestCase(true)]
public async Task LoadedItemRefreshPreservesOptionsBeforeOrAfterTransferAsync(bool updateBeforeTransfer)
{
var initialFilter = new DataChangeFilter { Trigger = DataChangeTrigger.StatusValue };
var updatedFilter = new DataChangeFilter { Trigger = DataChangeTrigger.StatusValueTimestamp };
await using TestMonitoredItem item = CreateLoadedItem(initialFilter);
if (updateBeforeTransfer)
{
UpdateMonitoringParameters(updatedFilter);
}
item.SetTransferResult(42, 9);
if (!updateBeforeTransfer)
{
UpdateMonitoringParameters(updatedFilter);
}
ArrayOf<MonitoredItemModifyRequest> requests = ApplyPendingModifications(item);
Assert.That(requests.Count, Is.EqualTo(2));
AssertUpdatedMonitoringParameters(requests[^1], updatedFilter);
Assert.That(item.CurrentSamplingInterval, Is.EqualTo(TimeSpan.FromMilliseconds(250)));
Assert.That(item.CurrentQueueSize, Is.EqualTo(31));
Assert.That(item.CurrentMonitoringMode, Is.EqualTo(MonitoringMode.Reporting));
Assert.That(item.ServerId, Is.EqualTo(9));
Assert.That(item.HasPendingChanges, Is.False);
}
/// <summary>
/// A null and an empty IndexRange both mean "not used" (Part 4
/// §7.27). A restored item carries "" from the snapshot while the
@@ -689,6 +827,78 @@ namespace Opc.Ua.Client.Subscriptions.MonitoredItems
Assert.That(result, Is.EqualTo($"Test#{monitoredItem.ClientHandle}|0 (TestItem)"));
}
/// <summary>
/// Creates a healthy saved item that needs its revised parameters refreshed after transfer.
/// </summary>
private TestMonitoredItem CreateLoadedItem(MonitoringFilter filter)
{
m_options.Configure(options => options with
{
StartNodeId = new NodeId("loaded", 2),
MonitoringMode = MonitoringMode.Reporting,
SamplingInterval = TimeSpan.FromMilliseconds(20),
QueueSize = 17,
DiscardOldest = true,
Filter = filter
});
var item = new TestMonitoredItem(m_context, m_options, m_telemetry.CreateLogger("MonitoredItem"));
item.ApplyLoadState(new MonitoredItemLoadState("loaded", m_options, 42, 7, []));
Assert.That(item.HasPendingChanges, Is.False);
return item;
}
/// <summary>
/// Applies a distinct set of desired monitoring parameters through the real options callback.
/// </summary>
private void UpdateMonitoringParameters(MonitoringFilter filter)
{
m_options.Configure(options => options with
{
SamplingInterval = TimeSpan.FromMilliseconds(250),
QueueSize = 31,
DiscardOldest = false,
Filter = filter
});
}
/// <summary>
/// Completes every FIFO change using its requested parameters as the server's revised result.
/// </summary>
private static ArrayOf<MonitoredItemModifyRequest> ApplyPendingModifications(MonitoredItem item)
{
var requests = new List<MonitoredItemModifyRequest>();
while (item.TryGetPendingChange(out MonitoredItem.Change? change))
{
Assert.That(change.ForceRecreate, Is.False);
Assert.That(change.MonitoringModeChange, Is.Null);
MonitoredItemModifyRequest request = change.BindModifyRequest()!;
Assert.That(request, Is.Not.Null);
requests.Add(request);
change.SetModifyResult(request, new MonitoredItemModifyResult
{
StatusCode = StatusCodes.Good,
RevisedSamplingInterval = request.RequestedParameters.SamplingInterval,
RevisedQueueSize = request.RequestedParameters.QueueSize
}, 0, [], new ResponseHeader());
}
return requests.ToArrayOf();
}
/// <summary>
/// Checks the exact latest parameters and preserved transferred identifiers sent to the server.
/// </summary>
private static void AssertUpdatedMonitoringParameters(
MonitoredItemModifyRequest request, DataChangeFilter filter)
{
Assert.That(request.MonitoredItemId, Is.EqualTo(9));
Assert.That(request.RequestedParameters.ClientHandle, Is.EqualTo(42));
Assert.That(request.RequestedParameters.SamplingInterval, Is.EqualTo(250));
Assert.That(request.RequestedParameters.QueueSize, Is.EqualTo(31));
Assert.That(request.RequestedParameters.DiscardOldest, Is.False);
Assert.That(request.RequestedParameters.Filter.TryGetValue(out DataChangeFilter? actual), Is.True);
Assert.That(actual, Is.SameAs(filter));
}
private sealed class TestMonitoredItem : MonitoredItem
{
public TestMonitoredItem(IMonitoredItemContext subscription,
@@ -2115,6 +2115,55 @@ namespace Opc.Ua.Client.Subscriptions
}
}
/// <summary>
/// An initiated transfer has its own confirmation callback; a failed handle rebind must not claim success.
/// </summary>
[TestCase(false)]
[TestCase(true)]
public async Task InitiatedTransferReportsOnlySuccessfulHandleRebindAsync(bool succeeds)
{
m_mockSubscriptionServices.Setup(service => service.DeleteSubscriptionsAsync(
It.IsAny<RequestHeader>(), It.IsAny<ArrayOf<uint>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new DeleteSubscriptionsResponse { Results = [StatusCodes.Good] });
m_mockMonitoredItemServices.Setup(service => service.CreateMonitoredItemsAsync(
It.IsAny<RequestHeader>(), It.IsAny<uint>(), It.IsAny<TimestampsToReturn>(),
It.IsAny<ArrayOf<MonitoredItemCreateRequest>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new CreateMonitoredItemsResponse
{
Results =
[
new MonitoredItemCreateResult
{
StatusCode = StatusCodes.Good,
MonitoredItemId = 9,
RevisedSamplingInterval = 1000,
RevisedQueueSize = 17
}
]
});
var subscription = new TestSubscription(m_session, m_mockNotificationDataHandler.Object,
m_completion, m_options, m_telemetry, 2);
await using (subscription.ConfigureAwait(false))
{
Assert.That(subscription.MonitoredItems.TryAdd("known",
OptionsFactory.Create<MonitoredItems.MonitoredItemOptions>(), out IMonitoredItem? item), Is.True);
Assert.That(item!.Created, Is.True);
SetupGetMonitoredItemsResult(succeeds ? StatusCodes.BadMethodInvalid : StatusCodes.BadTimeout);
subscription.ResetPublishStateForTest();
bool transferred = await subscription.TryCompleteTransferAsync([], CancellationToken.None)
.ConfigureAwait(false);
Assert.That(transferred, Is.EqualTo(succeeds));
Assert.That(subscription.LastPublishState & PublishState.Transferred,
Is.EqualTo(succeeds ? PublishState.Transferred : PublishState.None));
m_mockNotificationDataHandler.Verify(handler => handler.OnSubscriptionStateChangedAsync(
subscription, It.IsAny<SubscriptionState>(),
It.Is<PublishState>(state => (state & PublishState.Transferred) != 0)),
succeeds ? Times.Once() : Times.Never());
}
}
/// <summary>
/// An unusable GetMonitoredItems answer is treated like a server that
/// does not provide the optional method: the server ids the client
@@ -0,0 +1,167 @@
/* ========================================================================
* 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.Collections.Generic;
using System.Linq;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Moq;
using NUnit.Framework;
using Opc.Ua.Server.Hosting;
namespace Opc.Ua.Server.Tests.Hosting
{
/// <summary>
/// Verifies that hosted presets retain role configuration and explicit role-manager ownership.
/// </summary>
[TestFixture]
[Category("Hosting")]
public sealed class HostedRolePresetTests
{
/// <summary>
/// Applies either form of role configuration after selecting a hosted preset.
/// </summary>
[TestCase(false, false)]
[TestCase(false, true)]
[TestCase(true, false)]
[TestCase(true, true)]
public void PresetAppliesConfiguredRoleIdentityRules(bool secure, bool configurationSection)
{
var services = new ServiceCollection();
services.AddLogging();
IOpcUaServerBuilder server = AddPreset(services, secure);
if (configurationSection)
{
IConfiguration configuration = new ConfigurationBuilder().AddInMemoryCollection(
new Dictionary<string, string?>
{
["Roles:0:Name"] = BrowseNames.WellKnownRole_Operator,
["Roles:0:Identities:0:CriteriaType"] = nameof(IdentityCriteriaType.UserName),
["Roles:0:Identities:0:Criteria"] = "preset-operator"
}).Build();
server.ConfigureRoles(configuration);
}
else
{
server.ConfigureRoles(AddOperatorRule);
}
using ServiceProvider provider = services.BuildServiceProvider();
RequireOperatorRule(provider.GetRequiredService<IRoleManager>());
Assert.That(services.Count(descriptor => descriptor.ServiceType == typeof(IRoleManager)), Is.EqualTo(1));
}
/// <summary>
/// Resolves previously registered options lazily even when no subsequent ConfigureRoles call is made.
/// </summary>
[TestCase(false)]
[TestCase(true)]
public void PresetConsumesPreviouslyRegisteredRoleOptionsOnce(bool secure)
{
var services = new ServiceCollection();
services.AddLogging();
int configured = 0;
services.Configure<RoleConfigurationOptions>(options =>
{
configured++;
AddOperatorRule(options);
});
AddPreset(services, secure);
Assert.That(configured, Is.Zero);
using ServiceProvider provider = services.BuildServiceProvider();
IRoleManager roles = provider.GetRequiredService<IRoleManager>();
RequireOperatorRule(roles);
Assert.That(provider.GetRequiredService<IRoleManager>(), Is.SameAs(roles));
Assert.That(configured, Is.EqualTo(1));
}
/// <summary>
/// Keeps an explicit manager authoritative regardless of its position relative to the preset.
/// </summary>
[TestCase(false, false)]
[TestCase(false, true)]
[TestCase(true, false)]
[TestCase(true, true)]
public void PresetPreservesExplicitRoleManager(bool secure, bool registerBeforePreset)
{
var manager = new Mock<IRoleManager>(MockBehavior.Strict);
var services = new ServiceCollection();
services.AddLogging();
if (registerBeforePreset)
{
services.AddSingleton(manager.Object);
}
IOpcUaServerBuilder server = AddPreset(services, secure);
if (!registerBeforePreset)
{
server.AddRoleManager(manager.Object);
}
server.ConfigureRoles(AddOperatorRule);
using ServiceProvider provider = services.BuildServiceProvider();
Assert.That(provider.GetRequiredService<IRoleManager>(), Is.SameAs(manager.Object));
Assert.That(services.Count(descriptor => descriptor.ServiceType == typeof(IRoleManager)), Is.EqualTo(1));
manager.VerifyNoOtherCalls();
}
private static IOpcUaServerBuilder AddPreset(IServiceCollection services, bool secure)
{
IOpcUaBuilder builder = services.AddOpcUa();
return secure
? builder.AddSecureServer(static _ => { })
: builder.AddReferenceServer();
}
private static void AddOperatorRule(RoleConfigurationOptions options)
{
options.Roles.Add(new RoleDefinitionOptions
{
Name = BrowseNames.WellKnownRole_Operator,
Identities =
{
new RoleIdentityMappingOptions
{
CriteriaType = IdentityCriteriaType.UserName,
Criteria = "preset-operator"
}
}
});
}
private static void RequireOperatorRule(IRoleManager roles)
{
RoleEntry role = roles.GetRole(ObjectIds.WellKnownRole_Operator) ??
throw new AssertionException("The well-known Operator role is missing.");
Assert.That(role.Identities, Has.Exactly(1).Matches<IdentityMappingRuleType>(rule =>
rule.CriteriaType == IdentityCriteriaType.UserName && rule.Criteria == "preset-operator"));
}
}
}