/* ======================================================================== * 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