/* ========================================================================
* 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 NUnit.Framework;
using Opc.Ua.Client.FileSystem;
using Opc.Ua.Client.Tests.SessionContexts;
namespace Opc.Ua.Client.Tests.FileSystem
{
///
/// Verifies bounded transfers and private handle ownership through real generation-checked dispatch.
///
[TestFixture]
[Parallelizable(ParallelScope.All)]
public sealed class SessionFileHandleTests
{
///
/// Neither a write nor disposal can deliver an old handle to a recreated session.
///
[Test]
public async Task CapturedFileHandleRejectsWriteAndCleanupAfterRecreation()
{
await using var fixture = new SessionRequestContextTestContext();
Script(fixture);
await using SessionRequestContext context =
await SessionRequestContext.CaptureAsync(fixture.ManagedSession).ConfigureAwait(false);
SessionFileHandle handle = await context.OpenFileAsync(s_file, 6).ConfigureAwait(false);
await using (handle.ConfigureAwait(false))
{
fixture.Recreate();
using var source = new MemoryStream([1]);
ServiceResultException failure = (await Assert.ThrowsAsync(
async () => await handle.WriteFromAsync(source, 1).ConfigureAwait(false)))!;
Assert.That(failure.StatusCode, Is.EqualTo(StatusCodes.BadSessionIdInvalid));
Assert.That(handle.SessionId, Is.EqualTo(NodeId.Parse("s=original-session")));
}
Assert.That(Methods(fixture), Is.EqualTo([MethodIds.FileType_Open]));
}
///
/// Open is checked after waiting for recovery, not just when the caller obtained the context.
///
[Test]
public async Task CapturedOpenRejectsGenerationChangeAtDispatchGate()
{
await using var fixture = new SessionRequestContextTestContext();
Script(fixture);
await using SessionRequestContext context =
await SessionRequestContext.CaptureAsync(fixture.ManagedSession).ConfigureAwait(false);
Task opening;
using (await fixture.ServiceGate.WriterLockAsync().ConfigureAwait(false))
{
opening = context.OpenFileAsync(s_file, 6).AsTask();
Assert.That(opening.IsCompleted, Is.False);
fixture.Recreate();
}
ServiceResultException failure = (await Assert.ThrowsAsync(
async () => await opening.ConfigureAwait(false)))!;
Assert.That(failure.StatusCode, Is.EqualTo(StatusCodes.BadSessionIdInvalid));
Assert.That(fixture.Requests, Is.Empty);
}
///
/// Short reads are drained, the exact byte bound is accepted, and the terminal status is retained.
///
[Test]
public async Task ScopedDownloadContinuesShortReadsAndPreservesCloseResult()
{
await using var fixture = new SessionRequestContextTestContext();
var chunks = new Queue([ByteString.From([1]), ByteString.From([2, 3]), ByteString.Empty]);
var expected = new CallMethodResult
{
StatusCode = StatusCodes.GoodResultsMayBeIncomplete,
InputArgumentResults = [StatusCodes.BadOutOfRange],
InputArgumentDiagnosticInfos = [new DiagnosticInfo { AdditionalInfo = "close diagnostic" }]
};
Script(fixture, chunks, (method, _) => method.MethodId == MethodIds.FileType_Close ? expected : null);
await using SessionRequestContext context =
await SessionRequestContext.CaptureAsync(fixture.ManagedSession).ConfigureAwait(false);
using var destination = new MemoryStream();
(long total, CallMethodResult result) = await context.DownloadToStreamWithResultAsync(
s_file, destination, 3, 4).ConfigureAwait(false);
Assert.That(total, Is.EqualTo(3));
Assert.That(destination.ToArray(), Is.EqualTo(new byte[] { 1, 2, 3 }));
Assert.That(result, Is.SameAs(expected));
Assert.That(Methods(fixture), Is.EqualTo(
[
MethodIds.FileType_Open, MethodIds.FileType_Read, MethodIds.FileType_Read,
MethodIds.FileType_Read, MethodIds.FileType_Close
]));
}
///
/// Oversized server chunks are refused before any bytes reach the destination.
///
[Test]
public async Task ScopedDownloadRejectsOversizedChunkBeforeWriting()
{
await using var fixture = new SessionRequestContextTestContext();
Script(fixture, new Queue([ByteString.From([1, 2, 3, 4])]));
await using SessionRequestContext context =
await SessionRequestContext.CaptureAsync(fixture.ManagedSession).ConfigureAwait(false);
using var destination = new MemoryStream();
await Assert.ThrowsAsync(async () => await context.DownloadToStreamWithResultAsync(
s_file, destination, 3, 4).ConfigureAwait(false));
Assert.That(destination.Length, Is.Zero);
Assert.That(Methods(fixture), Is.EqualTo(
[
MethodIds.FileType_Open, MethodIds.FileType_Read, MethodIds.FileType_Close
]));
}
///
/// Invalid or already oversized seekable uploads do not allocate a remote handle.
///
[TestCase(0)]
[TestCase(2)]
public async Task ScopedUploadRejectsInvalidBoundBeforeOpen(int limit)
{
await using var fixture = new SessionRequestContextTestContext();
Script(fixture);
await using SessionRequestContext context =
await SessionRequestContext.CaptureAsync(fixture.ManagedSession).ConfigureAwait(false);
using var source = new MemoryStream([1, 2, 3]);
if (limit == 0)
{
await Assert.ThrowsAsync(
async () => await context.UploadFromWithResultAsync(s_file, source, limit).ConfigureAwait(false));
}
else
{
await Assert.ThrowsAsync(
async () => await context.UploadFromWithResultAsync(s_file, source, limit).ConfigureAwait(false));
}
Assert.That(fixture.Requests, Is.Empty);
Assert.That(source.Position, Is.Zero);
}
///
/// Nonseekable short reads are counted as they arrive, before the excess chunk is written.
///
[Test]
public async Task ScopedUploadBoundsNonSeekableShortReads()
{
await using var fixture = new SessionRequestContextTestContext();
Script(fixture);
await using SessionRequestContext context =
await SessionRequestContext.CaptureAsync(fixture.ManagedSession).ConfigureAwait(false);
using var source = new ShortSource([1, 2, 3, 4]);
await Assert.ThrowsAsync(
async () => await context.UploadFromWithResultAsync(s_file, source, 3, 4).ConfigureAwait(false));
Assert.That(Methods(fixture), Is.EqualTo(
[
MethodIds.FileType_Open, MethodIds.FileType_Write, MethodIds.FileType_Close
]));
CallMethodRequest write = ((CallRequest)fixture.Requests[1]).MethodsToCall[0];
Assert.That(write.InputArguments[1].TryGetValue(out ByteString bytes), Is.True);
Assert.That(bytes, Is.EqualTo(ByteString.From([1, 2])));
Assert.That(source.CanRead, Is.True);
}
///
/// A successful temporary commit remains successful when cancellation and recreation follow its effect.
///
[Test]
public async Task TemporaryUploadPreservesCommitAfterCancellationAndRecreation()
{
await using var fixture = new SessionRequestContextTestContext();
using var cancellation = new CancellationTokenSource();
NodeId completion = new(90u, 1);
var expected = new CallMethodResult
{
StatusCode = StatusCodes.GoodCompletesAsynchronously,
OutputArguments = [Variant.From(completion)]
};
Script(fixture, overrideResult: (method, _) =>
{
if (method.MethodId != MethodIds.TemporaryFileTransferType_CloseAndCommit)
{
return null;
}
fixture.Recreate();
cancellation.Cancel();
return expected;
});
await using SessionRequestContext context =
await SessionRequestContext.CaptureAsync(fixture.ManagedSession).ConfigureAwait(false);
var transfer = new TemporaryFileTransferClient(
context.Session, s_transfer, new FileSystemClientOptions { ChunkSize = 2 });
using var source = new MemoryStream([1, 2, 3]);
(long count, NodeId file, NodeId state, CallMethodResult result) = await transfer.UploadFromAsync(
context, source, 3, ct: cancellation.Token).ConfigureAwait(false);
Assert.That(count, Is.EqualTo(3));
Assert.That(file, Is.EqualTo(s_file));
Assert.That(state, Is.EqualTo(completion));
Assert.That(result, Is.SameAs(expected));
Assert.That(cancellation.IsCancellationRequested, Is.True);
Assert.That(Methods(fixture), Is.EqualTo(
[
MethodIds.TemporaryFileTransferType_GenerateFileForWrite,
MethodIds.FileType_Write, MethodIds.FileType_Write,
MethodIds.TemporaryFileTransferType_CloseAndCommit
]));
}
///
/// Temporary reads use the generated handle, preserve short reads and close without an extra Open.
///
[Test]
public async Task TemporaryDownloadUsesGeneratedHandleAndPreservesBound()
{
await using var fixture = new SessionRequestContextTestContext();
Script(fixture, new Queue([ByteString.From([1]), ByteString.From([2, 3]), ByteString.Empty]));
await using SessionRequestContext context =
await SessionRequestContext.CaptureAsync(fixture.ManagedSession).ConfigureAwait(false);
var transfer = new TemporaryFileTransferClient(
context.Session, s_transfer, new FileSystemClientOptions { ChunkSize = 2 });
using var destination = new MemoryStream();
(long count, NodeId file, CallMethodResult result) = await transfer.DownloadToAsync(
context, destination, 3).ConfigureAwait(false);
Assert.That(count, Is.EqualTo(3));
Assert.That(file, Is.EqualTo(s_file));
Assert.That(destination.ToArray(), Is.EqualTo(new byte[] { 1, 2, 3 }));
Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.Good));
Assert.That(Methods(fixture), Is.EqualTo(
[
MethodIds.TemporaryFileTransferType_GenerateFileForRead,
MethodIds.FileType_Read, MethodIds.FileType_Read, MethodIds.FileType_Read, MethodIds.FileType_Close
]));
}
///
/// Readiness metadata from an old generation never causes a Read or Close on its replacement.
///
[Test]
public async Task TemporaryReadReadinessRejectsReplacementBeforeReadOrCleanup()
{
await using var fixture = new SessionRequestContextTestContext();
fixture.Handler = (request, _) =>
{
if (request is CallRequest)
{
return new ValueTask(new CallResponse
{
Results =
[
new CallMethodResult
{
OutputArguments =
[Variant.From(s_file), Variant.From(kHandle), Variant.From(new NodeId(99u, 1))]
}
]
});
}
Assert.That(request, Is.TypeOf());
fixture.Recreate();
return new ValueTask(new TranslateBrowsePathsToNodeIdsResponse
{
Results =
[
new BrowsePathResult
{
Targets =
[
new BrowsePathTarget
{
TargetId = new ExpandedNodeId(100u, 1),
RemainingPathIndex = uint.MaxValue
}
]
}
]
});
};
await using SessionRequestContext context =
await SessionRequestContext.CaptureAsync(fixture.ManagedSession).ConfigureAwait(false);
var transfer = new TemporaryFileTransferClient(context.Session, s_transfer);
ServiceResultException failure = (await Assert.ThrowsAsync(
async () => await transfer.GenerateFileForReadAsync(context).ConfigureAwait(false)))!;
Assert.That(failure.StatusCode, Is.EqualTo(StatusCodes.BadSessionIdInvalid));
Assert.That(fixture.Requests, Has.Count.EqualTo(2));
Assert.That(fixture.Requests[0], Is.TypeOf());
Assert.That(fixture.Requests[1], Is.TypeOf());
}
///
/// A refused commit is returned unchanged and only the original file handle is closed.
///
[Test]
public async Task TemporaryCommitPreservesRefusalAndClosesOriginalHandle()
{
await using var fixture = new SessionRequestContextTestContext();
var expected = new CallMethodResult { StatusCode = StatusCodes.BadUserAccessDenied };
Script(fixture, overrideResult: (method, _) =>
method.MethodId == MethodIds.TemporaryFileTransferType_CloseAndCommit ? expected : null);
await using SessionRequestContext context =
await SessionRequestContext.CaptureAsync(fixture.ManagedSession).ConfigureAwait(false);
var transfer = new TemporaryFileTransferClient(context.Session, s_transfer);
using var source = new MemoryStream([1]);
(long count, _, NodeId state, CallMethodResult result) = await transfer.UploadFromAsync(
context, source, 1).ConfigureAwait(false);
Assert.That(count, Is.EqualTo(1));
Assert.That(state.IsNull, Is.True);
Assert.That(result, Is.SameAs(expected));
Assert.That(Methods(fixture), Is.EqualTo(
[
MethodIds.TemporaryFileTransferType_GenerateFileForWrite,
MethodIds.FileType_Write, MethodIds.TemporaryFileTransferType_CloseAndCommit, MethodIds.FileType_Close
]));
CallMethodRequest close = ((CallRequest)fixture.Requests[3]).MethodsToCall[0];
Assert.That(close.ObjectId, Is.EqualTo(s_file));
Assert.That(close.InputArguments, Is.EqualTo([Variant.From(kHandle)]));
}
///
/// A client cannot adopt another captured context, even for the same current server session.
///
[Test]
public async Task TemporaryClientRejectsAnotherContextBeforeGenerate()
{
await using var fixture = new SessionRequestContextTestContext();
Script(fixture);
await using SessionRequestContext original =
await SessionRequestContext.CaptureAsync(fixture.ManagedSession).ConfigureAwait(false);
await using SessionRequestContext other =
await SessionRequestContext.CaptureAsync(fixture.ManagedSession).ConfigureAwait(false);
var transfer = new TemporaryFileTransferClient(original.Session, s_transfer);
await Assert.ThrowsAsync(
async () => await transfer.GenerateFileForWriteAsync(other).ConfigureAwait(false));
Assert.That(fixture.Requests, Is.Empty);
}
///
/// A malformed successful commit is known to be committed and is not followed by an unsafe Close.
///
[Test]
public async Task MalformedCompletedCommitDoesNotCloseConsumedHandle()
{
await using var fixture = new SessionRequestContextTestContext();
Script(fixture, overrideResult: (method, _) =>
method.MethodId == MethodIds.TemporaryFileTransferType_CloseAndCommit
? new CallMethodResult { StatusCode = StatusCodes.Good, OutputArguments = [] } : null);
await using SessionRequestContext context =
await SessionRequestContext.CaptureAsync(fixture.ManagedSession).ConfigureAwait(false);
var transfer = new TemporaryFileTransferClient(context.Session, s_transfer);
SessionFileHandle handle = await transfer.GenerateFileForWriteAsync(context).ConfigureAwait(false);
await using (handle.ConfigureAwait(false))
{
ServiceResultException failure = (await Assert.ThrowsAsync(
async () => await handle.CommitAsync().ConfigureAwait(false)))!;
Assert.That(failure.StatusCode, Is.EqualTo(StatusCodes.BadDecodingError));
Assert.That(handle.IsCommitted, Is.True);
}
Assert.That(Methods(fixture), Is.EqualTo(
[
MethodIds.TemporaryFileTransferType_GenerateFileForWrite,
MethodIds.TemporaryFileTransferType_CloseAndCommit
]));
}
///
/// Installs a minimal FileType and TemporaryFileTransfer service script.
///
private static void Script(
SessionRequestContextTestContext fixture, Queue? chunks = null,
Func? overrideResult = null)
{
fixture.Handler = (request, token) =>
{
var call = (CallRequest)request;
Assert.That(call.MethodsToCall.Count, Is.EqualTo(1));
CallMethodRequest method = call.MethodsToCall[0];
CallMethodResult? result = overrideResult?.Invoke(method, token);
ArrayOf output = method.MethodId == MethodIds.FileType_Open
? [Variant.From(kHandle)]
: method.MethodId == MethodIds.FileType_Read
? [Variant.From(chunks!.Dequeue())]
: method.MethodId == MethodIds.TemporaryFileTransferType_GenerateFileForWrite
? [Variant.From(s_file), Variant.From(kHandle)]
: method.MethodId == MethodIds.TemporaryFileTransferType_GenerateFileForRead
? [Variant.From(s_file), Variant.From(kHandle), Variant.From(NodeId.Null)]
: [];
return new ValueTask(new CallResponse
{
Results = [result ?? new CallMethodResult { OutputArguments = output }]
});
};
}
///
/// Gets only the methods that reached the transport.
///
private static NodeId[] Methods(SessionRequestContextTestContext fixture)
{
return [.. fixture.Requests.Cast().Select(request => request.MethodsToCall[0].MethodId)];
}
///
/// Produces two-byte reads without exposing an advance length.
///
private sealed class ShortSource(byte[] bytes) : MemoryStream(bytes)
{
///
public override bool CanSeek => false;
///
public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken ct)
{
return base.ReadAsync(buffer, offset, Math.Min(count, 2), ct);
}
#if NET6_0_OR_GREATER
///
public override ValueTask ReadAsync(Memory buffer, CancellationToken ct = default)
{
return base.ReadAsync(buffer[..Math.Min(buffer.Length, 2)], ct);
}
#endif
}
private const uint kHandle = 73;
private static readonly NodeId s_file = new(80u, 1);
private static readonly NodeId s_transfer = new(81u, 1);
}
}