Files
UA-.NETStandard/tests/Opc.Ua.Client.Tests/Session/SessionByteStreamExtensionsTests.cs
Marc Schier be50f02700 Add additional companion spec MCP tool profiles (#4731)
# Description

Expose existing stack APIs through **337 additional domain MCP tools**:
114 industrial-companion tools, 35 WoT/xRegistry/document tools, and 188
higher-level stack-workflow tools. The fully enabled catalogue contains
**492 tools**; profile selection is not an execution grant.

The packages now follow the reviewed structure:
**`Opc.Ua.Mcp.Workflows`** contains roles/users, files, alarms and
history; **`Opc.Ua.Mcp.WotCon`** contains management/documents and
independently registered live WoT execution. GDS and schema tools remain
separate packages. The executable composes **18 libraries**.

| Workflow | Added domain tools | Profiles |
| --- | ---: | --- |
| GDS directory, certificates, reference-only credentials/tokens and
Part 21 onboarding | 46 | `gds`, `certificates`, `credentials`,
`onboarding` |
| Roles/users, regular and temporary files, alarms/conditions and
historical access | 74 | `roles`, `users`, `files`, `alarms`, `history`
|
| Bounded runtime types, XSD/BSD/UA JSON, PubSub and REST OpenAPI
artifacts | 6 | `schemas` |
| Named PubSub transports, remote configuration, declarative
adapters/bridges and SKS | 52 | `pubsub-runtime`,
`pubsub-configuration`, `pubsub-bridge`, `pubsub-security` |
| Live HTTP(S), MQTT, Modbus TCP and OPC UA WoT forms | 10 |
`wot-runtime` |

- Reuse typed clients, generated proxies, SDK transports, schema
providers and injected services rather than introducing another session
manager or protocol engine.
- Require host enablement plus exact caller/session/secret/network
grants. Protected generic Call/Write/node-management/history/file paths
enforce the same policy.
- Bind classifications, dispatches, file handles and historical cursors
to actual UA generations. Recovery cannot retarget an old handle or
authorized mutation. Preserve acknowledged results after late
cancellation; do not imply atomicity or retry business commands.
- Use opaque secret-store references, explicit writable-store
registration, issuer/resource/caller binding and reserved capacity
before issuance. Report remote success/local persistence failure as
partial completion; redact provider-controlled diagnostic text.
- Bound new named runtimes to eight per host, two per caller,
five-minute idle expiry and a thirty-minute absolute lease by default.
Pending/closing resources consume quota; failed cleanup remains visible.
Credential invalidation and session recovery close dependent runtimes.
- Keep document planning separate from execution, artifacts
root-confined and real JSON inputs typed. Preserve old profile ordinals
while widening the set to 64 bits. CI grades fully enabled privileged
catalogues rather than connection-only views.

**Limitations:** live Kafka remains explicitly unavailable because the
backend cannot enforce policy on broker-discovered connections;
configuration/schema/capability inspection stays available. The latest
public Dekaf v1.23.5 was also checked and its DNS injection remains
internal. No private-API workaround or bootstrap-only security claim was
added. Full legacy Core registration retains its documented
JSON-reflection limitation: native tests isolate its four connection
descriptors and validate the expanded production registrations, not
whole-host NativeAOT support.

## Related Issues

- Fixes #4730.

## Checklist

_Put an `x` in the boxes that apply. You can complete these step by step
after opening the PR._

- [ ] I have signed the
[CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf)
and read the
[CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md)
doc.
- [x] I have added tests that prove my fix is effective or that my
feature works and increased code coverage.
- [x] I have added all necessary documentation.
- [x] I have verified that my changes do not introduce (new) build or
analyzer warnings.
- [ ] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed.
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [x] I have addressed **all** PR feedback received.
2026-10-14 00:14:05 +02:00

359 lines
16 KiB
C#

/* Copyright (c) 2026 The OPC Foundation, Inc. All rights reserved.
* Licensed under the MIT License. See LICENSE.txt in the project root. */
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.Tests;
namespace Opc.Ua.Client.Tests
{
/// <summary>
/// Bounded ranged reads are asserted at the Read service and destination boundaries.
/// </summary>
[TestFixture]
[Category("Client")]
[Parallelizable]
public sealed class SessionByteStreamExtensionsTests
{
[SetUp]
public void SetUp()
{
m_requests.Clear();
m_values.Clear();
m_context = ServiceMessageContext.Create(NUnitTelemetryContext.Create());
m_session = new Mock<ISessionClient>(MockBehavior.Strict);
m_session.SetupGet(s => s.MessageContext).Returns(m_context);
m_session.Setup(s => s.ReadAsync(
It.IsAny<RequestHeader>(), It.IsAny<double>(), It.IsAny<TimestampsToReturn>(),
It.IsAny<ArrayOf<ReadValueId>>(), It.IsAny<CancellationToken>()))
.Returns<RequestHeader, double, TimestampsToReturn, ArrayOf<ReadValueId>, CancellationToken>(
(_, age, timestamps, requests, token) =>
{
Assert.That(requests.Count, Is.EqualTo(1));
Assert.That(age, Is.Zero);
Assert.That(timestamps, Is.EqualTo(TimestampsToReturn.Neither));
m_requests.Add((requests[0], token));
return new ValueTask<ReadResponse>(new ReadResponse
{
ResponseHeader = new ResponseHeader(),
Results = [m_values.Dequeue()]
});
});
}
[TestCase(false)]
[TestCase(true)]
public async Task ReadBytesToAsyncAdvancesRangesAfterShortReadsUntilExplicitEndAsync(bool noData)
{
m_values.Enqueue(Value(10, 11));
m_values.Enqueue(Value(12));
m_values.Enqueue(Value(13, 14));
m_values.Enqueue(noData
? DataValue.FromStatusCode(StatusCodes.BadIndexRangeNoData)
: Value());
using var destination = new MemoryStream();
using var cancellation = new CancellationTokenSource();
long count = await m_session.Object.ReadBytesToAsync(
new NodeId("content", 3), destination, maxBytes: 5, chunkSize: 4,
cancellation.Token).ConfigureAwait(false);
Assert.Multiple(() =>
{
Assert.That(count, Is.EqualTo(5));
Assert.That(destination.ToArray(), Is.EqualTo(new byte[] { 10, 11, 12, 13, 14 }));
Assert.That(destination.CanWrite, Is.True);
Assert.That(m_requests.Select(r => r.Request.IndexRange),
Is.EqualTo(s_shortReadRanges));
Assert.That(m_requests.All(r => r.Request.NodeId == new NodeId("content", 3)), Is.True);
Assert.That(m_requests.All(r => r.Request.AttributeId == Attributes.Value), Is.True);
Assert.That(m_requests.All(r => r.Token == cancellation.Token), Is.True);
});
}
[TestCase(false)]
[TestCase(true)]
public async Task ReadBytesToAsyncEmptyInitialValueReturnsZeroWithoutAnotherReadAsync(bool noData)
{
m_values.Enqueue(noData
? DataValue.FromStatusCode(StatusCodes.BadIndexRangeNoData)
: Value());
using var destination = new MemoryStream();
long count = await ReadAsync(destination, 1, 1).ConfigureAwait(false);
Assert.That(count, Is.Zero);
Assert.That(destination.Length, Is.Zero);
Assert.That(destination.CanWrite, Is.True);
Assert.That(m_requests.Select(r => r.Request.IndexRange), Is.EqualTo(s_singleByteRange));
}
[Test]
public void ReadBytesToAsyncRejectsCumulativeLimitBeforeWritingOffendingChunk()
{
m_values.Enqueue(Value(1, 2, 3));
m_values.Enqueue(Value(4, 5, 6));
using var destination = new MemoryStream();
Assert.ThrowsAsync<IOException>(async () =>
await ReadAsync(destination, maxBytes: 5, chunkSize: 4).ConfigureAwait(false));
Assert.That(destination.ToArray(), Is.EqualTo(new byte[] { 1, 2, 3 }));
Assert.That(m_requests.Select(r => r.Request.IndexRange), Is.EqualTo(s_cumulativeRanges));
Assert.That(destination.CanWrite, Is.True);
}
[Test]
public void ReadBytesToAsyncRejectsServerIgnoringRequestedRangeBeforeWriting()
{
m_values.Enqueue(Value(1, 2, 3, 4, 5));
using var destination = new MemoryStream();
Assert.ThrowsAsync<IOException>(async () =>
await ReadAsync(destination, maxBytes: 20, chunkSize: 4).ConfigureAwait(false));
Assert.That(destination.Length, Is.Zero);
Assert.That(destination.CanWrite, Is.True);
Assert.That(m_requests.Select(r => r.Request.IndexRange), Is.EqualTo(s_initialRange));
}
[Test]
public async Task ReadBytesToAsyncHonorsSessionByteStringCapAsync()
{
m_context.MaxByteStringLength = 2;
m_values.Enqueue(Value(21, 22));
m_values.Enqueue(Value(23));
m_values.Enqueue(Value());
using var destination = new MemoryStream();
long count = await ReadAsync(destination, maxBytes: 3, chunkSize: 9).ConfigureAwait(false);
Assert.That(count, Is.EqualTo(3));
Assert.That(destination.ToArray(), Is.EqualTo(new byte[] { 21, 22, 23 }));
Assert.That(m_requests.Select(r => r.Request.IndexRange), Is.EqualTo(s_cappedRanges));
}
[TestCase(0)]
[TestCase(-1)]
public async Task ReadBytesToAsyncNonPositiveSessionCapDoesNotShrinkRequestAsync(int cap)
{
m_context.MaxByteStringLength = cap;
m_values.Enqueue(Value(31));
m_values.Enqueue(Value());
using var destination = new MemoryStream();
long count = await ReadAsync(destination, 1, 4).ConfigureAwait(false);
Assert.That(count, Is.EqualTo(1));
Assert.That(destination.ToArray(), Is.EqualTo(new byte[] { 31 }));
Assert.That(m_requests.Select(r => r.Request.IndexRange), Is.EqualTo(s_uncappedRanges));
}
[TestCase("string")]
[TestCase("integer")]
public void ReadBytesToAsyncRejectsWrongValueType(string kind)
{
m_values.Enqueue(new DataValue(kind == "string" ? Variant.From("not bytes") : Variant.From(17)));
using var destination = new MemoryStream();
ServiceResultException exception = Assert.ThrowsAsync<ServiceResultException>(
async () => await ReadAsync(destination, 8, 4).ConfigureAwait(false))!;
Assert.That(exception.StatusCode, Is.EqualTo(StatusCodes.BadTypeMismatch));
Assert.That(destination.Length, Is.Zero);
Assert.That(destination.CanWrite, Is.True);
Assert.That(m_requests, Has.Count.EqualTo(1));
}
[TestCaseSource(nameof(s_badValueStatuses))]
public void ReadBytesToAsyncPreservesBadValueStatusAndPreviouslyWrittenBytes(StatusCode status)
{
m_values.Enqueue(Value(41, 42));
m_values.Enqueue(DataValue.FromStatusCode(status));
using var destination = new MemoryStream();
ServiceResultException exception = Assert.ThrowsAsync<ServiceResultException>(
async () => await ReadAsync(destination, 8, 4).ConfigureAwait(false))!;
Assert.That(exception.StatusCode, Is.EqualTo(status));
Assert.That(destination.ToArray(), Is.EqualTo(new byte[] { 41, 42 }));
Assert.That(destination.CanWrite, Is.True);
Assert.That(m_requests, Has.Count.EqualTo(2));
}
[TestCase(false)]
[TestCase(true)]
public void ReadBytesToAsyncRejectsMalformedResponse(bool diagnostics)
{
m_session.Setup(s => s.ReadAsync(
It.IsAny<RequestHeader>(), It.IsAny<double>(), It.IsAny<TimestampsToReturn>(),
It.IsAny<ArrayOf<ReadValueId>>(), It.IsAny<CancellationToken>()))
.Returns(new ValueTask<ReadResponse>(new ReadResponse
{
ResponseHeader = new ResponseHeader(),
Results = diagnostics ? new[] { Value(1) }.ToArrayOf() : [],
DiagnosticInfos = diagnostics
? new[] { new DiagnosticInfo(), new DiagnosticInfo() }.ToArrayOf() : []
}));
using var destination = new MemoryStream();
ServiceResultException exception = Assert.ThrowsAsync<ServiceResultException>(
async () => await ReadAsync(destination, 8, 4).ConfigureAwait(false))!;
Assert.That(exception.StatusCode, Is.EqualTo(StatusCodes.BadUnexpectedError));
Assert.That(destination.Length, Is.Zero);
Assert.That(destination.CanWrite, Is.True);
VerifyReads(Times.Once());
}
[Test]
public void ReadBytesToAsyncPropagatesCancellationAndRetainsDestination()
{
using var destination = new MemoryStream();
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
var primary = new OperationCanceledException(cancellation.Token);
m_session.Setup(s => s.ReadAsync(
It.IsAny<RequestHeader>(), It.IsAny<double>(), It.IsAny<TimestampsToReturn>(),
It.IsAny<ArrayOf<ReadValueId>>(), cancellation.Token))
.Throws(primary);
OperationCanceledException exception = Assert.ThrowsAsync<OperationCanceledException>(
async () => await m_session.Object.ReadBytesToAsync(
new NodeId("content", 3), destination, 8, 4, cancellation.Token).ConfigureAwait(false))!;
Assert.That(exception, Is.SameAs(primary));
Assert.That(destination.Length, Is.Zero);
Assert.That(destination.CanWrite, Is.True);
VerifyReads(Times.Once());
}
[Test]
public void ReadBytesToAsyncPreservesServiceExceptionAndRetainsDestination()
{
var primary = new ServiceResultException(StatusCodes.BadSessionClosed);
using var destination = new MemoryStream();
m_session.Setup(s => s.ReadAsync(
It.IsAny<RequestHeader>(), It.IsAny<double>(), It.IsAny<TimestampsToReturn>(),
It.IsAny<ArrayOf<ReadValueId>>(), It.IsAny<CancellationToken>()))
.Throws(primary);
ServiceResultException exception = Assert.ThrowsAsync<ServiceResultException>(
async () => await ReadAsync(destination, 8, 4).ConfigureAwait(false))!;
Assert.That(exception, Is.SameAs(primary));
Assert.That(destination.Length, Is.Zero);
Assert.That(destination.CanWrite, Is.True);
VerifyReads(Times.Once());
}
[TestCase(0L, 4, "maxBytes")]
[TestCase(-1L, 4, "maxBytes")]
[TestCase(2147483648L, 4, "maxBytes")]
[TestCase(8L, 0, "chunkSize")]
[TestCase(8L, -1, "chunkSize")]
[TestCase(8L, 1048577, "chunkSize")]
public void ReadBytesToAsyncValidatesBoundsBeforeRead(long maxBytes, int chunkSize, string parameter)
{
using var destination = new MemoryStream();
ArgumentOutOfRangeException exception = Assert.ThrowsAsync<ArgumentOutOfRangeException>(
async () => await ReadAsync(destination, maxBytes, chunkSize).ConfigureAwait(false))!;
Assert.That(exception.ParamName, Is.EqualTo(parameter));
Assert.That(destination.Length, Is.Zero);
Assert.That(destination.CanWrite, Is.True);
VerifyReads(Times.Never());
}
[TestCase(1L, 1)]
[TestCase(2147483647L, 1048576)]
public async Task ReadBytesToAsyncAcceptsLimitBoundariesAsync(long maxBytes, int chunkSize)
{
m_context.MaxByteStringLength = 0;
m_values.Enqueue(Value(51));
m_values.Enqueue(Value());
using var destination = new MemoryStream();
long count = await ReadAsync(destination, maxBytes, chunkSize).ConfigureAwait(false);
Assert.That(count, Is.EqualTo(1));
Assert.That(destination.ToArray(), Is.EqualTo(new byte[] { 51 }));
Assert.That(m_requests[0].Request.IndexRange, Is.EqualTo(chunkSize == 1 ? "0:0" : "0:1048575"));
}
[Test]
public void ReadBytesToAsyncRejectsReadOnlyDestinationWithoutRead()
{
using var destination = new MemoryStream([1, 2], writable: false);
ArgumentException exception = Assert.ThrowsAsync<ArgumentException>(
async () => await ReadAsync(destination, 8, 4).ConfigureAwait(false))!;
Assert.That(exception.ParamName, Is.EqualTo("destination"));
Assert.That(destination.ToArray(), Is.EqualTo(new byte[] { 1, 2 }));
Assert.That(destination.CanRead, Is.True);
VerifyReads(Times.Never());
}
[Test]
public void ReadBytesToAsyncRejectsNullDestinationWithoutRead()
{
ArgumentNullException exception = Assert.ThrowsAsync<ArgumentNullException>(
async () => await ReadAsync(null!, 8, 4).ConfigureAwait(false))!;
Assert.That(exception.ParamName, Is.EqualTo("destination"));
VerifyReads(Times.Never());
}
[Test]
public void ReadBytesToAsyncRejectsNullSession()
{
using var destination = new MemoryStream();
ArgumentNullException exception = Assert.ThrowsAsync<ArgumentNullException>(
async () => await SessionByteStreamExtensions.ReadBytesToAsync(
null!, NodeId.Null, destination, 8).ConfigureAwait(false))!;
Assert.That(exception.ParamName, Is.EqualTo("session"));
Assert.That(destination.Length, Is.Zero);
}
private Task<long> ReadAsync(Stream destination, long maxBytes, int chunkSize)
{
return m_session.Object.ReadBytesToAsync(
new NodeId("content", 3), destination, maxBytes, chunkSize).AsTask();
}
private void VerifyReads(Times times)
{
m_session.Verify(s => s.ReadAsync(
It.IsAny<RequestHeader>(), It.IsAny<double>(), It.IsAny<TimestampsToReturn>(),
It.IsAny<ArrayOf<ReadValueId>>(), It.IsAny<CancellationToken>()), times);
}
private static DataValue Value(params byte[] bytes)
{
return new DataValue(Variant.From(ByteString.From(bytes)));
}
private static readonly StatusCode[] s_badValueStatuses =
[
StatusCodes.BadUserAccessDenied, StatusCodes.BadNodeIdUnknown
];
private static readonly string[] s_shortReadRanges = ["0:3", "2:5", "3:6", "5:8"];
private static readonly string[] s_singleByteRange = ["0:0"];
private static readonly string[] s_cumulativeRanges = ["0:3", "3:6"];
private static readonly string[] s_initialRange = ["0:3"];
private static readonly string[] s_cappedRanges = ["0:1", "2:3", "3:4"];
private static readonly string[] s_uncappedRanges = ["0:3", "1:4"];
private ServiceMessageContext m_context = null!;
private Mock<ISessionClient> m_session = null!;
private readonly Queue<DataValue> m_values = new();
private readonly List<(ReadValueId Request, CancellationToken Token)> m_requests = [];
}
}