/* ======================================================================== * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. * * OPC Foundation MIT License 1.00 * * Permission is hereby granted, free of charge, to any person * obtaining a copy of this software and associated documentation * files (the "Software"), to deal in the Software without * restriction, including without limitation the rights to use, * copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the * Software is furnished to do so, subject to the following * conditions: * * The above copyright notice and this permission notice shall be * included in all copies or substantial portions of the Software. * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR * OTHER DEALINGS IN THE SOFTWARE. * * The complete license agreement can be found here: * http://opcfoundation.org/License/MIT/1.00/ * ======================================================================*/ using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Moq; using NUnit.Framework; using Opc.Ua.Client.FileSystem; using Opc.Ua.Client.TestFramework; using Opc.Ua.Tests; namespace Opc.Ua.Client.Tests.FileSystem { /// /// Unit tests for that /// validate the temp-write commit lifecycle (CloseAndCommit vs /// Close, single terminal call). /// [TestFixture] [Category("FileSystem")] [Parallelizable] public class TemporaryFileTransferClientTests { [Test] public async Task CommitAsyncSendsCloseAndCommitOnceAsync() { using var harness = TempTransferHarness.Create(); UaTemporaryWriteFile temp = await harness .GenerateForWriteAsync().ConfigureAwait(false); await harness.WriteSomeBytesAsync(temp).ConfigureAwait(false); NodeId completion = await temp.CommitAsync().ConfigureAwait(false); await temp.DisposeAsync().ConfigureAwait(false); await temp.CommitAsync().ConfigureAwait(false); Assert.That(harness.CloseAndCommitCount, Is.EqualTo(1)); Assert.That(harness.CloseCount, Is.Zero); Assert.That(completion, Is.EqualTo(new NodeId(99))); } [Test] public async Task DisposeWithoutCommitSendsCloseAsync() { using var harness = TempTransferHarness.Create(); UaTemporaryWriteFile temp = await harness .GenerateForWriteAsync().ConfigureAwait(false); await temp.DisposeAsync().ConfigureAwait(false); await temp.DisposeAsync().ConfigureAwait(false); Assert.That(harness.CloseCount, Is.EqualTo(1)); Assert.That(harness.CloseAndCommitCount, Is.Zero); } [Test] public async Task GenerateForReadReturnsStreamThatClosesHandleOnDisposeAsync() { using var harness = TempTransferHarness.Create(); UaFileStream stream = await harness.Client .GenerateFileForReadAsync(default, CancellationToken.None) .ConfigureAwait(false); await stream.DisposeAsync().ConfigureAwait(false); Assert.That(harness.CloseCount, Is.EqualTo(1)); } [Test] public async Task GenerateForReadWaitsForReadTransferStateAsync() { using var harness = TempTransferHarness.Create(); harness.UseCompletionStateMachine( ObjectIds.FileTransferStateMachineType_ReadPrepare, ObjectIds.FileTransferStateMachineType_ReadPrepare, ObjectIds.FileTransferStateMachineType_ReadTransfer); UaFileStream stream = await harness.Client .GenerateFileForReadAsync(default, CancellationToken.None) .ConfigureAwait(false); Assert.That(harness.StateReads, Is.EqualTo(3), "Read must only be possible once the state reached ReadTransfer."); await stream.DisposeAsync().ConfigureAwait(false); } [Test] public async Task GenerateForReadKeepsWaitingWhileStateIsIdleAsync() { // Idle means generation has not started yet; only ReadTransfer // makes Read possible (Part 20 4.4.3). using var harness = TempTransferHarness.Create(); harness.UseCompletionStateMachine( ObjectIds.FileTransferStateMachineType_Idle, ObjectIds.FileTransferStateMachineType_ReadPrepare, ObjectIds.FileTransferStateMachineType_ReadTransfer); UaFileStream stream = await harness.Client .GenerateFileForReadAsync(default, CancellationToken.None) .ConfigureAwait(false); Assert.That(harness.StateReads, Is.EqualTo(3)); await stream.DisposeAsync().ConfigureAwait(false); } [Test] public void GenerateForReadThrowsAndClosesHandleWhenStateMachineFails() { using var harness = TempTransferHarness.Create(); harness.UseCompletionStateMachine( ObjectIds.FileTransferStateMachineType_ReadPrepare, ObjectIds.FileTransferStateMachineType_Error); Assert.That( async () => await harness.Client .GenerateFileForReadAsync(default, CancellationToken.None) .ConfigureAwait(false), Throws.TypeOf()); Assert.That(harness.CloseCount, Is.EqualTo(1)); } [Test] public async Task TempStreamWrapperDisposeDoesNotCloseHandleAsync() { using var harness = TempTransferHarness.Create(); UaTemporaryWriteFile temp = await harness .GenerateForWriteAsync().ConfigureAwait(false); // Disposing the wrapped Stream must NOT close the server handle. temp.Stream.Dispose(); Assert.That(harness.CloseCount, Is.Zero); Assert.That(harness.CloseAndCommitCount, Is.Zero); await temp.CommitAsync().ConfigureAwait(false); Assert.That(harness.CloseAndCommitCount, Is.EqualTo(1)); } private sealed class TempTransferHarness : IDisposable { private readonly Dictionary> m_handlers = []; private TempTransferHarness( TemporaryFileTransferClient client, Mock sessionMock) { Client = client; SessionMock = sessionMock; } public TemporaryFileTransferClient Client { get; } public Mock SessionMock { get; } public int StateReads { get; private set; } /// /// Makes GenerateFileForRead return a completion state machine /// whose CurrentState/Id reads as in /// turn (the last one repeats). /// public void UseCompletionStateMachine(params NodeId[] states) { var stateMachine = new NodeId(500); var stateIdNode = new NodeId(501); m_handlers[Methods.TemporaryFileTransferType_GenerateFileForRead] = _ => [ new Variant(new NodeId(123)), new Variant(7u), new Variant(stateMachine) ]; SessionMock .Setup(s => s.TranslateBrowsePathsToNodeIdsAsync( It.IsAny(), It.IsAny>(), It.IsAny())) .Returns, CancellationToken>( (_, paths, _) => { Assert.That(paths[0].StartingNode, Is.EqualTo(stateMachine)); return new ValueTask( new TranslateBrowsePathsToNodeIdsResponse { ResponseHeader = new ResponseHeader(), Results = new[] { new BrowsePathResult { StatusCode = StatusCodes.Good, Targets = new[] { new BrowsePathTarget { TargetId = stateIdNode, RemainingPathIndex = uint.MaxValue } }.ToArrayOf() } }.ToArrayOf() }); }); SessionMock .Setup(s => s.ReadAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny())) .Returns, CancellationToken>( (_, _, _, nodes, _) => { Assert.That(nodes[0].NodeId, Is.EqualTo(stateIdNode)); NodeId state = states[Math.Min(StateReads, states.Length - 1)]; StateReads++; return new ValueTask(new ReadResponse { ResponseHeader = new ResponseHeader(), Results = new[] { new DataValue(new Variant(state)) }.ToArrayOf() }); }); } public int CloseAndCommitCount { get; private set; } public int CloseCount { get; private set; } public static TempTransferHarness Create() { ITelemetryContext telemetry = NUnitTelemetryContext.Create(); IServiceMessageContext messageContext = ServiceMessageContext.Create(telemetry); var sessionMock = new Mock(MockBehavior.Loose); sessionMock.SetupGet(s => s.MessageContext).Returns(messageContext); TempTransferHarness? harness = null; sessionMock .Setup(s => s.CallAsync( It.IsAny(), It.IsAny>(), It.IsAny())) .Returns, CancellationToken>( (_, requests, _) => { CallMethodRequest req = requests[0]; req.MethodId.TryGetValue(out uint methodId); Variant[] outputs = harness!.m_handlers[methodId](req); var result = new CallMethodResult { StatusCode = StatusCodes.Good, OutputArguments = outputs.ToArrayOf() }; var response = new CallResponse { ResponseHeader = new ResponseHeader(), Results = new[] { result }.ToArrayOf(), DiagnosticInfos = default }; return new ValueTask(response); }); var client = new TemporaryFileTransferClient( sessionMock.Object, new NodeId(1000)); harness = new TempTransferHarness(client, sessionMock); harness.RegisterHandlers(); return harness; } public async Task GenerateForWriteAsync() { return await Client .GenerateFileForWriteAsync(default, CancellationToken.None) .ConfigureAwait(false); } public async Task WriteSomeBytesAsync(UaTemporaryWriteFile temp) { byte[] payload = [1, 2, 3]; #if NET await temp.Stream .WriteAsync(payload.AsMemory(), CancellationToken.None) .ConfigureAwait(false); #else await temp.Stream .WriteAsync(payload, 0, payload.Length, CancellationToken.None) .ConfigureAwait(false); #endif } public void Dispose() { // No-op (mock-only). } private void RegisterHandlers() { m_handlers[Methods.FileType_Close] = _ => { CloseCount++; return []; }; m_handlers[Methods.FileType_Write] = _ => []; m_handlers[Methods.FileType_SetPosition] = _ => []; m_handlers[Methods.FileType_Read] = _ => [new Variant(Array.Empty().ToByteString())]; // GenerateFileForRead → (NodeId, uint handle, NodeId completionStateMachine). m_handlers[Methods.TemporaryFileTransferType_GenerateFileForRead] = _ => [ new Variant(new NodeId(123)), new Variant(7u), new Variant(NodeId.Null) ]; // GenerateFileForWrite → (NodeId, uint handle). m_handlers[Methods.TemporaryFileTransferType_GenerateFileForWrite] = _ => [ new Variant(new NodeId(123)), new Variant(7u) ]; // CloseAndCommit → NodeId. m_handlers[Methods.TemporaryFileTransferType_CloseAndCommit] = _ => { CloseAndCommitCount++; return [new Variant(new NodeId(99))]; }; } } } }