/* ========================================================================
* 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
{
///
/// Verifies bounded generated-file copying through real session dispatch and managed transport leases.
///
[TestFixture]
[Parallelizable(ParallelScope.All)]
public sealed class TemporaryFileTransferClientBoundedReadTests
{
///
/// A recreated session cannot receive an old handle even when it reuses the public session identifier.
///
[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(
async () => await client.CopyToAsync(default, destination, 3).ConfigureAwait(false))!;
Assert.That(failure.InnerExceptions, Has.Count.EqualTo(2));
Assert.That(failure.InnerExceptions, Has.All.TypeOf()
.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()), Times.Never);
fixture.Channel.Verify(channel => channel.Dispose(), Times.Never);
}
///
/// Recovery queued behind generation is rechecked at the next dispatch, not just before entering the gate.
///
[Test]
public async Task CopyRejectsRecoveryBetweenGenerationAndReadDispatchAsync()
{
await using var fixture = new SessionRequestContextTestContext();
var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var release = new TaskCompletionSource(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 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(
async () => await copying.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false))!;
Assert.That(failure.InnerExceptions, Has.Count.EqualTo(2));
Assert.That(failure.InnerExceptions, Has.All.TypeOf()
.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);
}
}
}
///
/// Cancellation cannot discard a generated handle returned through a managed transport lease.
///
[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());
using var cancellation = new CancellationTokenSource();
var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var release = new TaskCompletionSource(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(transport.CreateResponse(request));
var client = new TemporaryFileTransferClient(owner, s_transfer);
using var destination = new MemoryStream();
Task 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()).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);
}
}
///
/// Cleanup spends its independent deadline on the declaration Close call, not optional metadata discovery.
///
[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()), Times.Never);
fixture.Channel.Verify(channel => channel.Dispose(), Times.Never);
}
///
/// A non-Good generation result cannot hide a valid allocated handle from cleanup.
///
[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(
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);
}
///
/// Response-count and diagnostic errors are rejected only after retaining the returned file handle.
///
[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(
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);
}
///
/// A rejected generation without a complete handle cannot trigger an invented Close.
///
[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(
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);
}
///
/// Generation rejection and cleanup failure remain independently visible in their original order.
///
[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(
async () => await client.CopyToAsync(default, destination, 3).ConfigureAwait(false))!;
Assert.That(failure.InnerExceptions, Has.Count.EqualTo(2));
Assert.That(failure.InnerExceptions[0], Is.TypeOf()
.With.Property(nameof(ServiceResultException.StatusCode)).EqualTo(StatusCodes.Uncertain));
Assert.That(failure.InnerExceptions[1], Is.TypeOf()
.With.Property(nameof(ServiceResultException.StatusCode)).EqualTo(StatusCodes.BadUserAccessDenied));
Assert.That(script.Methods, Is.EqualTo(new[] { s_generateMethod, MethodIds.FileType_Close }));
}
///
/// Delaying generation validation does not weaken service or method validation for Read and Close.
///
[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(
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);
}
///
/// Assigns the selected service or method status without altering the returned output arguments.
///
private static void SetStatus(CallResponse response, bool serviceStatus, StatusCode status)
{
if (serviceStatus)
{
response.ResponseHeader.ServiceResult = status;
}
else
{
response.Results[0].StatusCode = status;
}
}
///
/// Provides deterministic temporary-file service responses while recording actual method dispatches.
///
private sealed class TransferScript
{
///
/// Gets the generation response, whose metadata tests may corrupt independently of its handle.
///
public CallResponse GenerationResponse { get; } = CreateResponse(
[Variant.From(s_file), Variant.From(kHandle), Variant.From(NodeId.Null)]);
///
/// Gets the Read response, with output populated from the configured chunks.
///
public CallResponse ReadResponse { get; } = CreateResponse([]);
///
/// Gets the Close response.
///
public CallResponse CloseResponse { get; } = CreateResponse([]);
///
/// Gets the payload chunks; unexpected Read calls fail when none remain.
///
public Queue Chunks { get; } = new();
///
/// Gets the methods that reached the transport.
///
public ArrayOf Methods => m_methods.ToArrayOf();
///
/// Gets the number of unnecessary Close-metadata lookups.
///
public int CloseDiscoveries { get; private set; }
///
/// Gets the cancellation token actually delivered to Close.
///
public CancellationToken CloseToken { get; private set; }
///
/// Gets or sets whether optional Close metadata is unavailable.
///
public bool RejectCloseDiscovery { get; init; }
///
/// Gets or sets the action between generating a handle and returning its response.
///
public Func? OnGenerate { get; set; }
///
/// Gets or sets the action before a generation-readiness response completes.
///
public Func? OnStateRead { get; set; }
///
/// Executes only the expected temporary-file protocol operations.
///
public async ValueTask 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());
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;
}
///
/// Creates one successful method response.
///
private static CallResponse CreateResponse(ArrayOf outputs)
{
return new CallResponse
{
ResponseHeader = new ResponseHeader(),
Results = [new CallMethodResult { OutputArguments = outputs }]
};
}
private readonly List 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);
}
}