/* ========================================================================
* 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/
* ======================================================================*/
#if NET10_0
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Threading;
using System.Threading.Tasks;
using Moq;
using Opc.Ua.Client;
using Opc.Ua.Client.TestFramework;
using Opc.Ua.ISA95.Server.Providers;
using Opc.Ua.MachineTool;
using Opc.Ua.MachineTool.Server;
using Opc.Ua.Machinery;
using Opc.Ua.Machinery.Jobs;
using Opc.Ua.Machinery.Server;
using Opc.Ua.Machinery.Server.Builders;
using Opc.Ua.Server;
using Opc.Ua.Server.FileSystem;
using Opc.Ua.Server.TestFramework;
using Opc.Ua.Tests;
using ISession = Opc.Ua.Client.ISession;
using V2 = Opc.Ua.ISA95.JobControl.V2;
namespace Opc.Ua.Tools.Tests
{
///
/// Composes actual MachineTool job sources and file providers behind real authenticated opc.tcp sessions.
///
internal sealed class MachineToolServiceMcpFixture : IAsyncDisposable
{
///
/// Creates isolated source engines, bounded provider mocks forwarding to real storage, and short local PKI.
///
internal MachineToolServiceMcpFixture()
{
m_server = new ServerFixture(telemetry => new ServiceServer(telemetry, m_password))
{
SecurityNone = true,
AutoAccept = true
};
Source = CreateSource();
IndependentSource = CreateSource();
m_receiver.Setup(receiver => receiver.ReceiveJobOrderAsync(
It.IsAny(), It.IsAny(),
It.IsAny>(), It.IsAny()))
.Returns((Isa95JobOrderOperationV2 operation, V2.ISA95JobOrderDataType order,
ArrayOf comments, CancellationToken ct) =>
{
ReceivedEncodingMask = order.EncodingMask;
return Source.ReceiveJobOrderAsync(operation, order, comments, ct);
});
Physical = new PhysicalFileSystemProvider(Path.Combine(m_root, "content"));
IndependentFiles = new PhysicalFileSystemProvider(Path.Combine(m_root, "other"));
Provider.SetupGet(provider => provider.MountName).Returns("Services");
Provider.SetupGet(provider => provider.IsWritable).Returns(() => !DenyWrites);
Provider.Setup(provider => provider.GetEntryAsync(It.IsAny(), It.IsAny()))
.Returns((string path, CancellationToken ct) => Physical.GetEntryAsync(path, ct));
Provider.Setup(provider => provider.EnumerateAsync(It.IsAny(), It.IsAny()))
.Returns((string path, CancellationToken ct) => Physical.EnumerateAsync(path, ct));
Provider.Setup(provider => provider.OpenReadAsync(It.IsAny(), It.IsAny()))
.Returns((string path, CancellationToken ct) => OpenAsync(path, null, ct));
Provider.Setup(provider => provider.OpenWriteAsync(
It.IsAny(), It.IsAny(), It.IsAny()))
.Returns((string path, FileWriteMode mode, CancellationToken ct) => OpenAsync(path, mode, ct));
Provider.Setup(provider => provider.CreateDirectoryAsync(It.IsAny(), It.IsAny()))
.Returns((string path, CancellationToken ct) => Physical.CreateDirectoryAsync(path, ct));
Provider.Setup(provider => provider.CreateFileAsync(It.IsAny(), It.IsAny()))
.Returns((string path, CancellationToken ct) => Physical.CreateFileAsync(path, ct));
Provider.Setup(provider => provider.DeleteAsync(It.IsAny(), It.IsAny()))
.Returns((string path, CancellationToken ct) =>
{
Mutations.Enqueue("Delete");
return Physical.DeleteAsync(path, ct);
});
Provider.Setup(provider => provider.MoveAsync(
It.IsAny(), It.IsAny(), It.IsAny()))
.Returns((string from, string to, CancellationToken ct) => Physical.MoveAsync(from, to, ct));
Provider.Setup(provider => provider.CopyAsync(
It.IsAny(), It.IsAny(), It.IsAny()))
.Returns((string from, string to, CancellationToken ct) => Physical.CopyAsync(from, to, ct));
}
///
/// Gets the authorized real UA session.
///
internal ISession Session { get; private set; } = null!;
///
/// Gets another authorized session with a different session identity and file-handle ownership.
///
internal ISession Peer { get; private set; } = null!;
///
/// Gets an actual anonymous UA session, denied standard file mutations and configured job controls.
///
internal ISession Anonymous { get; private set; } = null!;
///
/// Gets the full machine and its actual nested services.
///
internal MachineToolRuntime Machine { get; private set; } = null!;
///
/// Gets another machine with independent jobs/files and no WorkMasters selection.
///
internal MachineToolRuntime Independent { get; private set; } = null!;
///
/// Gets a machine without either optional service.
///
internal MachineToolRuntime Minimal { get; private set; } = null!;
///
/// Gets the real source that composes the existing ISA-95 engine and explicit responses/catalog.
///
internal MachineToolJobSource Source { get; }
///
/// Gets the isolated second source.
///
internal MachineToolJobSource IndependentSource { get; }
///
/// Gets real storage exposed only through the standard runtime's file binding.
///
internal PhysicalFileSystemProvider Physical { get; }
///
/// Gets the second root's independent storage.
///
internal PhysicalFileSystemProvider IndependentFiles { get; }
///
/// Gets the forwarding provider used to observe resource lifetime and simulate delayed storage.
///
internal Mock Provider { get; } = new(MockBehavior.Strict);
///
/// Gets or sets a provider refusal policy independent of caller consent and authenticated identity.
///
internal bool DenyWrites { get; set; }
///
/// Gets the number of provider streams not yet closed by the standard file handle binder.
///
internal int ActiveStreams => Volatile.Read(ref m_activeStreams);
///
/// Gets the actual decoded wire mask before the reference engine's canonical V1/V2 normalization.
///
internal uint ReceivedEncodingMask { get; private set; }
///
/// Gets observed writes/deletes to detect implicit retry or compensation.
///
internal ConcurrentQueue Mutations { get; } = new();
///
/// Gets the actual generated Jobs state, for asserting namespace-qualified service binding.
///
internal JobManagementState Management { get; private set; } = null!;
///
/// Starts isolated storage, the real endpoint, full/minimal machine instances, and three real sessions.
///
internal async Task StartAsync()
{
await Physical.CreateDirectoryAsync("WorkMasters", default).ConfigureAwait(false);
await SeedAsync(Physical, "file.bin", new byte[] { 1, 2, 3, 4 }).ConfigureAwait(false);
await SeedAsync(Physical, "WorkMasters/program.nc", new byte[] { 5, 6, 7 }).ConfigureAwait(false);
await SeedAsync(Physical, "large.bin", new byte[131072]).ConfigureAwait(false);
await SeedAsync(Physical, "pause.bin", new byte[] { 8, 9, 10 }).ConfigureAwait(false);
await SeedAsync(Physical, "empty.bin", ReadOnlyMemory.Empty).ConfigureAwait(false);
await SeedAsync(IndependentFiles, "file.bin", new byte[] { 99 }).ConfigureAwait(false);
await m_server.LoadConfigurationAsync(Path.Combine(m_root, "s")).ConfigureAwait(false);
m_server.Config.ServerConfiguration!.UserTokenPolicies =
[
new UserTokenPolicy(UserTokenType.Anonymous),
new UserTokenPolicy(UserTokenType.UserName) { SecurityPolicyUri = SecurityPolicies.Basic256Sha256 }
];
ServiceServer server = await m_server.StartAsync().ConfigureAwait(false);
IMachineryBuildContext context = server.Manager.CreateMachineryBuildContext();
Machine = await Define(context, "Services", Source)
.WithProduction(new MachineToolProductionOptions
{
Features = MachineToolFeatures.Production | MachineToolFeatures.DynamicProductionPlan
})
.WithJobManagement(new MachineToolJobManagementOptions
{
Receiver = m_receiver.Object,
ResponseProvider = Source,
Catalog = Source
})
.WithFileSystem(Provider.Object, includeWorkMasters: true)
.BuildAsync().ConfigureAwait(false);
var fileChildren = new List();
Machine.State.FileSystem!.GetChildren(context.Context, fileChildren);
FileState paused = fileChildren.OfType()
.Single(file => file.BrowseName.Name == "pause.bin");
paused.MaxByteStringLength!.Value = 1;
AuthorizeFiles(Machine.State.FileSystem, context.Context);
Independent = await Define(context, "Independent", IndependentSource)
.WithJobManagement(new MachineToolJobManagementOptions
{
Receiver = IndependentSource,
ResponseProvider = IndependentSource,
Catalog = IndependentSource
})
.WithFileSystem(IndependentFiles)
.BuildAsync().ConfigureAwait(false);
Minimal = await Define(context, "Minimal", new InMemoryMachineToolProvider(new MachineToolSnapshot()))
.BuildAsync().ConfigureAwait(false);
BaseInstanceState blocks = Machine.State.FindChild(context.Context,
new QualifiedName("MachineryBuildingBlocks",
server.Manager.SystemContext.NamespaceUris.GetIndexOrAppend(
Opc.Ua.Machinery.Namespaces.Machinery)))!;
Management = (JobManagementState)blocks.FindChild(context.Context,
new QualifiedName(Opc.Ua.Machinery.Jobs.BrowseNames.JobManagement,
server.Manager.SystemContext.NamespaceUris.GetIndexOrAppend(
Opc.Ua.Machinery.Jobs.Namespaces.MachineryJobs)))!;
var methods = new List();
Management.JobOrderControl!.GetChildren(context.Context, methods);
foreach (MethodState method in methods.OfType())
{
method.OnReadUserExecutable = AuthorizeCaller;
}
Session = await ConnectAsync("a", true).ConfigureAwait(false);
Peer = await ConnectAsync("b", true).ConfigureAwait(false);
Anonymous = await ConnectAsync("c", false).ConfigureAwait(false);
}
///
/// Observes the first real write or pauses a real asynchronous read until the test explicitly releases it.
///
internal IoGate Pause(bool write)
{
var gate = new IoGate(write);
m_gate = gate;
return gate;
}
///
/// Creates standard orders with an explicit known WorkMaster and optional priority field.
///
internal static V2.ISA95JobOrderDataType Order(string id) => new()
{
EncodingMask = (uint)(V2.ISA95JobOrderDataTypeFields.WorkMasterID |
V2.ISA95JobOrderDataTypeFields.Priority),
JobOrderID = id,
WorkMasterID = [new V2.ISA95WorkMasterDataType { ID = "WM-1" }]
};
///
/// Seeds application-owned provider data, not a second OPC UA file protocol.
///
internal static async Task SeedAsync(IFileSystemProvider provider, string path, ReadOnlyMemory content)
{
Stream stream = await provider.OpenWriteAsync(path, FileWriteMode.Truncate, default).ConfigureAwait(false);
await using (stream.ConfigureAwait(false))
{
await stream.WriteAsync(content).ConfigureAwait(false);
}
}
///
public async ValueTask DisposeAsync()
{
m_gate?.Release.TrySetResult(true);
foreach (ISession session in m_sessions)
{
if (session.Connected)
{
await session.CloseAsync(1000, true).ConfigureAwait(false);
}
if (session is IAsyncDisposable asynchronous)
{
await asynchronous.DisposeAsync().ConfigureAwait(false);
}
else
{
session.Dispose();
}
}
foreach (ClientFixture client in m_clients)
{
await client.DisposeAsync().ConfigureAwait(false);
}
if (Machine != null)
{
await Machine.DisposeAsync().ConfigureAwait(false);
}
if (Independent != null)
{
await Independent.DisposeAsync().ConfigureAwait(false);
}
if (Minimal != null)
{
await Minimal.DisposeAsync().ConfigureAwait(false);
}
await Source.DisposeAsync().ConfigureAwait(false);
await IndependentSource.DisposeAsync().ConfigureAwait(false);
await m_server.StopAsync().ConfigureAwait(false);
CryptographicOperations.ZeroMemory(m_password);
if (Directory.Exists(m_root))
{
Directory.Delete(m_root, recursive: true);
}
}
///
/// Authenticates both command sessions with ephemeral fixture-only credentials.
///
private async Task ConnectAsync(string name, bool authenticated)
{
var client = new ClientFixture(NUnitTelemetryContext.Create());
m_clients.Add(client);
await client.LoadClientConfigurationAsync(Path.Combine(m_root, name), name).ConfigureAwait(false);
ISession session = await client.ConnectAsync(new Uri($"opc.tcp://localhost:{m_server.Port}"),
SecurityPolicies.Basic256Sha256,
userIdentity: authenticated ? new UserIdentity("operator", m_password) : new UserIdentity())
.ConfigureAwait(false);
m_sessions.Add(session);
return session;
}
///
/// Uses server-side UserExecutable policy; MCP consent cannot turn an anonymous session into an operator.
///
private static ServiceResult AuthorizeCaller(ISystemContext context, NodeState node, ref bool value)
{
value = context is ISessionSystemContext { UserIdentity.TokenType: UserTokenType.UserName };
return ServiceResult.Good;
}
///
/// Configures the integrator-owned directory's server policy, independently of the stock provider policy.
///
private static void AuthorizeFiles(FileDirectoryState directory, ISystemContext context)
{
var children = new List();
directory.GetChildren(context, children);
foreach (BaseInstanceState child in children)
{
if (child is MethodState method)
{
method.OnReadUserExecutable = AuthorizeCaller;
}
else if (child is FileDirectoryState nested)
{
AuthorizeFiles(nested, context);
}
else if (child is FileState file)
{
file.Open!.OnReadUserExecutable = AuthorizeCaller;
}
}
}
///
/// Defines ordinary public MachineTool builder instances with application-owned providers.
///
private static MachineToolBuilder Define(
IMachineryBuildContext context, string name, IMachineToolProvider provider)
{
return context.AddMachineTool(name).WithProvider(provider).WithIdentification(identity =>
{
identity.Manufacturer = new LocalizedText("Service MCP tests");
identity.SerialNumber = name;
identity.ProductInstanceUri = "urn:service-mcp:" + name;
});
}
///
/// Declares independent source mappings, not implicit job-to-program guesses.
///
private static MachineToolJobSource CreateSource()
{
return new MachineToolJobSource(new MachineToolJobSourceOptions
{
Jobs = Enumerable.Range(0, 32).Select(index => new MachineToolJobSourceDescriptor
{
JobOrderId = "Job-" + index,
WorkMasterId = "WM-1",
ProgramName = "PROGRAM" + index + ".NC"
}).ToArrayOf(),
MaximumPendingRevisions = 1024
}, new MachineToolSnapshot
{
Operation = new MachineToolOperation { Mode = MachineOperationMode.Manual }
});
}
///
/// Observes actual provider streams without bypassing any standard server or client method.
///
private async ValueTask OpenAsync(string path, FileWriteMode? mode, CancellationToken ct)
{
Stream stream;
if (mode.HasValue)
{
if (DenyWrites)
{
throw new UnauthorizedAccessException("The application file provider refuses this write.");
}
Mutations.Enqueue("Write");
stream = await Physical.OpenWriteAsync(path, mode.Value, ct).ConfigureAwait(false);
}
else
{
stream = await Physical.OpenReadAsync(path, ct).ConfigureAwait(false);
}
Interlocked.Increment(ref m_activeStreams);
IoGate? gate = path == "pause.bin" && m_gate?.Write == mode.HasValue ? m_gate : null;
return new ObservedStream(stream, gate, () => Interlocked.Decrement(ref m_activeStreams));
}
///
/// Registers the real companion models and the modern server identity authenticator.
///
internal sealed class ServiceServer : StandardServer
{
///
/// Creates an isolated server whose credential exists only for this fixture lifetime.
///
internal ServiceServer(ITelemetryContext telemetry, byte[] password)
: base(telemetry)
{
m_password = password;
}
///
/// Gets the real shared Machinery node manager.
///
internal MachineryNodeManager Manager { get; private set; } = null!;
///
protected override ValueTask CreateMasterNodeManagerAsync(
IServerInternal server, ApplicationConfiguration configuration,
CancellationToken cancellationToken = default)
{
var options = new MachineryServerOptions
{
Parts = MachineryParts.BuildingBlocks | MachineryParts.Jobs
};
Manager = new MachineryNodeManager(server, configuration,
[new MachineryModelProvider(options.Parts), new MachineToolModelProvider()], options);
return new ValueTask(
new MasterNodeManager(server, configuration, null, [Manager]));
}
///
protected override void OnServerStarted(IServerInternal server)
{
base.OnServerStarted(server);
server.IdentityRegistry.Register(new AnonymousAuthenticator());
server.IdentityRegistry.Register(new UserNamePasswordAuthenticator((token, ct) =>
{
ct.ThrowIfCancellationRequested();
if (token.UserName != "operator" || token.DecryptedPassword == null ||
!CryptographicOperations.FixedTimeEquals(token.DecryptedPassword, m_password))
{
throw new ServiceResultException(StatusCodes.BadUserAccessDenied);
}
return new ValueTask(new UserIdentity(token));
}));
}
/// The ephemeral test credential, erased by the owning fixture.
private readonly byte[] m_password;
}
///
/// Signals actual provider I/O and stream disposal, with an optional asynchronous read barrier.
///
internal sealed class IoGate
{
///
/// Selects a write observation or asynchronous read pause.
///
internal IoGate(bool write)
{
Write = write;
}
///
/// Gets the selected operation.
///
internal bool Write { get; }
///
/// Gets a signal after the real file handle has reached provider I/O.
///
internal TaskCompletionSource Entered { get; } =
new(TaskCreationOptions.RunContinuationsAsynchronously);
///
/// Gets the test-controlled continuation, always released in cleanup.
///
internal TaskCompletionSource Release { get; } =
new(TaskCreationOptions.RunContinuationsAsynchronously);
///
/// Gets a signal after the server closes the actual provider stream.
///
internal TaskCompletionSource Closed { get; } =
new(TaskCreationOptions.RunContinuationsAsynchronously);
}
///
/// Wraps a real backing stream to observe writes/disposal and pause asynchronous reads.
///
private sealed class ObservedStream(Stream inner, IoGate? gate, Action onClose) : Stream
{
///
public override bool CanRead => inner.CanRead;
///
public override bool CanSeek => inner.CanSeek;
///
public override bool CanWrite => inner.CanWrite;
///
public override long Length => inner.Length;
///
public override long Position
{
get => inner.Position;
set => inner.Position = value;
}
///
public override void Flush() => inner.Flush();
///
public override long Seek(long offset, SeekOrigin origin) => inner.Seek(offset, origin);
///
public override void SetLength(long value) => inner.SetLength(value);
///
public override int Read(byte[] buffer, int offset, int count) => inner.Read(buffer, offset, count);
///
public override void Write(byte[] buffer, int offset, int count)
{
inner.Write(buffer, offset, count);
if (gate is { Write: true })
{
gate.Entered.TrySetResult(true);
}
}
///
public override async ValueTask ReadAsync(Memory buffer, CancellationToken ct = default)
{
if (gate is { Write: false })
{
gate.Entered.TrySetResult(true);
await gate.Release.Task.WaitAsync(ct).ConfigureAwait(false);
}
return await inner.ReadAsync(buffer, ct).ConfigureAwait(false);
}
///
public override async ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken ct = default)
{
if (gate is { Write: true })
{
gate.Entered.TrySetResult(true);
}
await inner.WriteAsync(buffer, ct).ConfigureAwait(false);
}
///
protected override void Dispose(bool disposing)
{
if (disposing && Interlocked.Exchange(ref m_disposed, 1) == 0)
{
inner.Dispose();
onClose();
gate?.Closed.TrySetResult(true);
}
base.Dispose(disposing);
}
/// The idempotent close gate for counter accounting.
private int m_disposed;
}
/// Short, repository-local, fixture-owned certificate and provider storage.
private readonly string m_root = Path.GetFullPath(Path.Combine(".ms-pki", Guid.NewGuid().ToString("N")[..8]));
/// Ephemeral credentials; never a real or hardcoded password.
private readonly byte[] m_password = System.Text.Encoding.UTF8.GetBytes(Guid.NewGuid().ToString("N"));
/// The real server fixture.
private readonly ServerFixture m_server;
/// Observes native decoded requests and forwards every verb to the actual source engine.
private readonly Mock m_receiver = new(MockBehavior.Strict);
/// The connected clients, disposed after their sessions.
private readonly List m_clients = [];
/// The real sessions owned exclusively by the fixture.
private readonly List m_sessions = [];
/// The optional bounded storage pause for the next selected file open.
private IoGate? m_gate;
/// The number of server-owned provider streams still open.
private int m_activeStreams;
}
}
#endif