mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-17 05:11:13 +08:00
162fc5ebb3
# Description Findings from running the CTT conformance groups **Monitored Item Services**, **Node Management Services**, **Security General** and **Security User Token** against `ConsoleReferenceServer --ctt` (CTT 1.05.06, scripts 1.05.513). The security groups ran **without `-a`**, against an isolated server PKI built from the CTT's `copyToServer` folders, so the negative certificate tests are meaningful. ## Server fixes ### AddNodes below one parent was quadratic (Node Management Delete Node `Err-002.js`) The CTT adds 15,000 variables below `ns=2;s=CTT` in batches of 5,000 and warned that AddNodes responses took 400–800 ms. For every added node the server searched all children of the parent twice: once in the BrowseName duplicate check in `AsyncCustomNodeManager.AddNodeCoreAsync`, which also copied the children list, and once in the NodeVersion lookup of `FilterChangesByNodeVersion`. - `NodeState.FindChild` now searches children lists with ≥ 64 entries through a BrowseName index kept in a `ConditionalWeakTable`, so nodes with few children get no extra field. - It keeps first-match semantics and is maintained by `AddChild`/`RemoveChild`. - It is dropped on replacement and invalidated when a child is renamed or moves to another parent. - Lists whose children point at another parent (clones) are still searched linearly. - The duplicate check uses `FindChild` instead of copying the list. | 5,000-node AddNodes batch (in-process, Debug) | 1st | 2nd | 3rd | | --- | --- | --- | --- | | before | 769 ms | 2,083 ms | 4,011 ms | | after | 141 ms | 166 ms | 172 ms | In the CTT, AddNodes of 5,000 items now answers in 51–72 ms and the warning is gone. ### OpenSecureChannel leaked revocation errors (Security Certificate Validation `002.js`) Part 4 §6.1.3, Table 106: - For the revocation check (`Bad_CertificateRevoked`, `Bad_CertificateIssuerRevoked`), `Bad_SecurityChecksFailed` **shall** be reported to the client. - For a missing revocation list (`Bad_CertificateRevocationUnknown`, `Bad_CertificateIssuerRevocationUnknown`), it **should** be. `TcpServerChannel` masked only `Bad_CertificateRevoked`, and `StandardServer.OnApplicationCertificateError` (CreateSession) had its own list that missed `Bad_CertificatePolicyCheckFailed`. Both now use one helper, `CertificateErrorReporting` in Opc.Ua.Core, which also inspects the nested errors of the certificate validator (both paths now throw the validation result with `ThrowIfInvalid`). Only validity period, host name, URI and usage errors keep their own code, and audit events still carry the specific result. The CTT warning is gone. ### Sample data - **Monitor Value Change V2 `020.js`** skipped because `Scalar_Static_Arrays_ByteString` had a 1-byte and a 3-byte element. The script checks every element, and the simulation only padded elements 0–2. The seed value now has no element shorter than four bytes, and the simulation pads every element. - **Monitor Value Change V2 `042.js`** got 18 of 19 notifications. The missing item is the Float matrix: its element [1,1] was `-1.03179664E+33`, where adding 1 is below Float precision, so the value really did not change. The server did not drop a notification. The element is now `-1031.79664`. ## CTT results after the fixes | Group | Result | | --- | --- | | Monitored Item Services (208) | Monitor Value Change V2 44/44. Remaining: Monitor Basic `039.js` (C17, missing include), `038.js` warning (C32), and 1–2 sporadic timestamp-tolerance warnings that move between test cases from run to run. | | Node Management Services (15) | Only Add Node `Err-008.js` (Mantis 11266). | | Security General (314) | Certificate Validation: 23 pass, 24 not implemented, 3 not-applicable skips. None CreateSession/ActivateSession: all pass. The other 50 CUs are *Not Implemented* in scripts 1.05.513. | | Security User Token (66) | Anonymous `002.js` (C35) and User Name Password 2 `015.js` (C36) fail because of CTT defects. X509: 18/18 automated cases pass. | ## CTT defects documented (`plans/ctt-issues.md`) - **C32** `038.js` compares a RevisedSamplingInterval of 0 with a project setting instead of the node's MinimumSamplingInterval or the server's MinSupportedSampleRate. - **C33** `020.js` requires every ByteString element to be ≥ 4 bytes, but its message says elements 0–2. - **C34** `RequestedNewNodeId()` always sends `ns=1`, regardless of `RequestedNodeId_Namespace`, so client NodeIds can't be enabled. - **C35** Security User Anonymous picks the `opc.wss` endpoint, which the CTT cannot open (`BadNotSupported`). - **C36** `UaEndpointDescription.FindTokenType` rewrites the cached endpoints' token policies, so `015.js` reports PolicyIds as reused. The server's GetEndpoints output was verified compliant (PolicyIds unique per configuration, Mantis 11258). - Evidence added to **C9** (042.js) and **issue 17** (015.js). `plans/ctt-testing.md` gains how to run the Monitored Item/Node Management groups and a recommended setup for the security groups: isolated PKI from `copyToServer`, no `-a`, PKI restore per part, and waiting for `Server started`. Other findings recorded there: - the Create/DeleteMonitoredItems timing warnings are jitter; - `LoginNameAccessDenied` is not a real user; - `samples\UAReferenceServer.ctt.xml` needs no change (enabling `RequestedNodeId` breaks six Add Node cases because of C34). **Note:** `plans/ctt-testing.md` and the refreshed `plans/ctt-issues.md` are introduced by #4482. The plans files here are based on #4482's versions, and new entries start at C32 because #4481 uses C19–C31. Please merge after #4482 (or rebase the plans files onto it). ## Tests - `NodeStateChildNameIndexTests` (Opc.Ua.Types.Tests): index semantics (duplicates, remove, rename, replace, re-parent, clone, shrink/grow, 2,000 random operations against a linear search). Also a scan guard: 5,000 misses against 50,000 children take 97 ms with the index and 7,083 ms with it disabled. - `AsyncCustomNodeManagerNodeManagementTests.AddNodeAsync_DuplicateBrowseNameUnderParentWithManyChildren…` - `TcpServerChannelCertificateErrorTests` (Opc.Ua.Core.Tests): masked vs reported certificate errors. - Opc.Ua.Server.Tests 5,553 passed, Opc.Ua.Core.Security.Tests 556 passed, Opc.Ua.Core.Tests (Security + Stack) 3,561 passed, InformationModel NodeManagement 26 passed (net10.0). - Opc.Ua.Types.Tests 10,404 passed and 1 failed: `WotSpecFixtureManifestTests.EveryEmbeddedExampleMatchesItsRecordedHash`. That test hashes WoT assets which are `eol=lf` in the index but CRLF in this worktree's checkout; no WoT file is touched by this PR. ## Checklist - [x] I have signed the CLA and read the CONTRIBUTING doc. - [x] I have added tests that prove my fix is effective or that my feature works. - [x] I have added all necessary documentation. - [ ] I ran all tests locally using UA.slnx against .NET Framework and .NET 10 (net10.0 suites listed above only). 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: romanett <7413710+romanett@users.noreply.github.com>
381 lines
14 KiB
C#
381 lines
14 KiB
C#
/* ========================================================================
|
|
* 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 NUnit.Framework;
|
|
using Opc.Ua.Bindings;
|
|
using Opc.Ua.Tests;
|
|
|
|
namespace Opc.Ua.Server.Tests
|
|
{
|
|
/// <summary>
|
|
/// Deterministic, offline unit tests for the guard clauses and state helpers
|
|
/// of <see cref="StandardServer"/> that are reachable without starting the server.
|
|
/// </summary>
|
|
[TestFixture]
|
|
[Category("StandardServer")]
|
|
[Parallelizable(ParallelScope.All)]
|
|
public class StandardServerCoverageTests
|
|
{
|
|
private sealed class TestableStandardServer : StandardServer
|
|
{
|
|
public TestableStandardServer(ITelemetryContext telemetry)
|
|
: base(telemetry)
|
|
{
|
|
}
|
|
|
|
public TestableStandardServer(ITelemetryContext telemetry, TimeProvider timeProvider)
|
|
: base(telemetry, timeProvider)
|
|
{
|
|
}
|
|
|
|
public TimeProvider TimeProviderAccessor => TimeProvider;
|
|
|
|
public void ValidateRequestPublic(RequestHeader requestHeader)
|
|
{
|
|
ValidateRequest(requestHeader);
|
|
}
|
|
|
|
public ValueTask<OperationContext> ValidateRequestInScopePublicAsync()
|
|
{
|
|
return ValidateRequestAsync(
|
|
null!,
|
|
new RequestHeader(),
|
|
RequestType.Read,
|
|
RequestLifetime.None);
|
|
}
|
|
|
|
public void SetServerStatePublic(ServerState state)
|
|
{
|
|
SetServerState(state);
|
|
}
|
|
|
|
public void SetServerErrorPublic(ServiceResult error)
|
|
{
|
|
SetServerError(error);
|
|
}
|
|
|
|
public void OnApplicationCertificateErrorPublic(ByteString clientCertificate, ServiceResult result)
|
|
{
|
|
OnApplicationCertificateError(clientCertificate, result);
|
|
}
|
|
|
|
public async Task InitializeAndDiscardServiceHostsAsync(
|
|
ApplicationConfiguration configuration,
|
|
ITransportBindingRegistry registry)
|
|
{
|
|
await InitializeServiceHostsAsync(configuration, registry).ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
private sealed class TestTimeProvider : TimeProvider;
|
|
|
|
private static TestableStandardServer CreateServer()
|
|
{
|
|
return new TestableStandardServer(NUnitTelemetryContext.Create());
|
|
}
|
|
|
|
[Test]
|
|
public void ConstructorUsesSystemTimeProviderByDefault()
|
|
{
|
|
using TestableStandardServer server = CreateServer();
|
|
|
|
Assert.That(server.TimeProviderAccessor, Is.SameAs(TimeProvider.System));
|
|
}
|
|
|
|
[Test]
|
|
public void ConstructorUsesProvidedTimeProvider()
|
|
{
|
|
var custom = new TestTimeProvider();
|
|
using var server = new TestableStandardServer(NUnitTelemetryContext.Create(), custom);
|
|
|
|
Assert.That(server.TimeProviderAccessor, Is.SameAs(custom));
|
|
}
|
|
|
|
[Test]
|
|
public void DisposeCanBeCalledMoreThanOnce()
|
|
{
|
|
TestableStandardServer server = CreateServer();
|
|
|
|
server.Dispose();
|
|
|
|
Assert.DoesNotThrow(server.Dispose);
|
|
}
|
|
|
|
[Test]
|
|
public void LoadComplexTypesDefaultsToTrue()
|
|
{
|
|
using TestableStandardServer server = CreateServer();
|
|
|
|
Assert.That(server.LoadComplexTypes, Is.True);
|
|
}
|
|
|
|
[Test]
|
|
public void LoadComplexTypesCanBeDisabled()
|
|
{
|
|
using TestableStandardServer server = CreateServer();
|
|
|
|
server.LoadComplexTypes = false;
|
|
|
|
Assert.That(server.LoadComplexTypes, Is.False);
|
|
}
|
|
|
|
[Test]
|
|
public void CurrentInstanceThrowsWhenNotStarted()
|
|
{
|
|
using TestableStandardServer server = CreateServer();
|
|
|
|
ServiceResultException ex = Assert.Throws<ServiceResultException>(
|
|
() => _ = server.CurrentInstance);
|
|
Assert.That(ex.StatusCode, Is.EqualTo((uint)StatusCodes.BadServerHalted));
|
|
}
|
|
|
|
[Test]
|
|
public void CurrentStateThrowsWhenNotStarted()
|
|
{
|
|
using TestableStandardServer server = CreateServer();
|
|
|
|
ServiceResultException ex = Assert.Throws<ServiceResultException>(
|
|
() => _ = server.CurrentState);
|
|
Assert.That(ex.StatusCode, Is.EqualTo((uint)StatusCodes.BadServerHalted));
|
|
}
|
|
|
|
[Test]
|
|
public void GetStatusThrowsWhenNotStarted()
|
|
{
|
|
using TestableStandardServer server = CreateServer();
|
|
|
|
#pragma warning disable CS0618 // GetStatus is obsolete but still exercised for coverage.
|
|
ServiceResultException ex = Assert.Throws<ServiceResultException>(
|
|
() => server.GetStatus());
|
|
#pragma warning restore CS0618
|
|
Assert.That(ex.StatusCode, Is.EqualTo((uint)StatusCodes.BadServerHalted));
|
|
}
|
|
|
|
[Test]
|
|
public void ValidateRequestThrowsWhenNotStarted()
|
|
{
|
|
using TestableStandardServer server = CreateServer();
|
|
|
|
ServiceResultException ex = Assert.Throws<ServiceResultException>(
|
|
() => server.ValidateRequestPublic(new RequestHeader()));
|
|
Assert.That(ex.StatusCode, Is.EqualTo((uint)StatusCodes.BadServerHalted));
|
|
}
|
|
|
|
[Test]
|
|
public Task ValidateRequestInScopeThrowsWhenNotStartedAsync()
|
|
{
|
|
using TestableStandardServer server = CreateServer();
|
|
|
|
ServiceResultException exception = Assert.ThrowsAsync<ServiceResultException>(
|
|
async () => await server
|
|
.ValidateRequestInScopePublicAsync()
|
|
.ConfigureAwait(false));
|
|
|
|
Assert.That(
|
|
exception.StatusCode,
|
|
Is.EqualTo((uint)StatusCodes.BadServerHalted));
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
[Test]
|
|
public void SetServerStateThrowsWhenNotStarted()
|
|
{
|
|
using TestableStandardServer server = CreateServer();
|
|
|
|
ServiceResultException ex = Assert.Throws<ServiceResultException>(
|
|
() => server.SetServerStatePublic(ServerState.Running));
|
|
Assert.That(ex.StatusCode, Is.EqualTo((uint)StatusCodes.BadServerHalted));
|
|
}
|
|
|
|
[Test]
|
|
public void SetServerErrorUpdatesServerErrorProperty()
|
|
{
|
|
using TestableStandardServer server = CreateServer();
|
|
var error = new ServiceResult(StatusCodes.BadInternalError);
|
|
|
|
server.SetServerErrorPublic(error);
|
|
|
|
Assert.That(server.ServerError, Is.EqualTo(error));
|
|
}
|
|
|
|
[Test]
|
|
public void ValidateRequestThrowsServerErrorWhenSet()
|
|
{
|
|
using TestableStandardServer server = CreateServer();
|
|
server.SetServerErrorPublic(new ServiceResult(StatusCodes.BadInternalError));
|
|
|
|
ServiceResultException ex = Assert.Throws<ServiceResultException>(
|
|
() => server.ValidateRequestPublic(new RequestHeader()));
|
|
Assert.That(ex.StatusCode, Is.EqualTo((uint)StatusCodes.BadInternalError));
|
|
}
|
|
|
|
[Test]
|
|
public void SetServerStateThrowsServerErrorWhenSet()
|
|
{
|
|
using TestableStandardServer server = CreateServer();
|
|
server.SetServerErrorPublic(new ServiceResult(StatusCodes.BadInternalError));
|
|
|
|
ServiceResultException ex = Assert.Throws<ServiceResultException>(
|
|
() => server.SetServerStatePublic(ServerState.Running));
|
|
Assert.That(ex.StatusCode, Is.EqualTo((uint)StatusCodes.BadInternalError));
|
|
}
|
|
|
|
[Test]
|
|
public void OnApplicationCertificateErrorMapsToSecurityChecksFailed()
|
|
{
|
|
StatusCode[] certStatusCodes =
|
|
[
|
|
StatusCodes.BadCertificateInvalid,
|
|
StatusCodes.BadCertificateRevoked,
|
|
StatusCodes.BadCertificateUntrusted,
|
|
StatusCodes.BadCertificateIssuerRevoked,
|
|
StatusCodes.BadCertificateRevocationUnknown,
|
|
StatusCodes.BadCertificateChainIncomplete,
|
|
StatusCodes.BadCertificateIssuerRevocationUnknown,
|
|
StatusCodes.BadCertificatePolicyCheckFailed
|
|
];
|
|
using TestableStandardServer server = CreateServer();
|
|
|
|
foreach (StatusCode certStatusCode in certStatusCodes)
|
|
{
|
|
ServiceResultException ex = Assert.Throws<ServiceResultException>(
|
|
() => server.OnApplicationCertificateErrorPublic(
|
|
ByteString.Empty,
|
|
new ServiceResult(certStatusCode)));
|
|
Assert.That(ex.StatusCode, Is.EqualTo((uint)StatusCodes.BadSecurityChecksFailed));
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public void OnApplicationCertificateErrorPassesThroughUnmappedCode()
|
|
{
|
|
using TestableStandardServer server = CreateServer();
|
|
|
|
ServiceResultException ex = Assert.Throws<ServiceResultException>(
|
|
() => server.OnApplicationCertificateErrorPublic(
|
|
ByteString.Empty,
|
|
new ServiceResult(StatusCodes.BadCertificateTimeInvalid)));
|
|
Assert.That(ex.StatusCode, Is.EqualTo((uint)StatusCodes.BadCertificateTimeInvalid));
|
|
}
|
|
|
|
[Test]
|
|
public void OnApplicationCertificateErrorMapsNestedRevocationErrorToSecurityChecksFailed()
|
|
{
|
|
// the certificate validator nests every failed check; an expired certificate whose
|
|
// issuer has no revocation list must not reveal the revocation state (Part 4 §6.1.3).
|
|
using TestableStandardServer server = CreateServer();
|
|
|
|
ServiceResultException ex = Assert.Throws<ServiceResultException>(
|
|
() => server.OnApplicationCertificateErrorPublic(
|
|
ByteString.Empty,
|
|
new ServiceResult(
|
|
StatusCodes.BadCertificateTimeInvalid,
|
|
new ServiceResult(StatusCodes.BadCertificateIssuerRevocationUnknown))));
|
|
Assert.That(ex.StatusCode, Is.EqualTo((uint)StatusCodes.BadSecurityChecksFailed));
|
|
}
|
|
|
|
[Test]
|
|
public void InitializeServiceHostsThrowsForUnregisteredScheme()
|
|
{
|
|
using TestableStandardServer server = CreateServer();
|
|
ApplicationConfiguration configuration = CreateConfiguration(
|
|
"opc.https://localhost:62540/TestServer");
|
|
var registry = new DefaultTransportBindingRegistry();
|
|
|
|
InvalidOperationException ex = Assert.ThrowsAsync<InvalidOperationException>(
|
|
async () => await server.InitializeAndDiscardServiceHostsAsync(configuration, registry)
|
|
.ConfigureAwait(false))!;
|
|
|
|
Assert.That(ex.Message, Does.Contain("opc.https"));
|
|
Assert.That(ex.Message, Does.Contain("AddHttpsTransport"));
|
|
}
|
|
|
|
[Test]
|
|
public async Task InitializeServiceHostsUsesRegisteredFactoryAsync()
|
|
{
|
|
using TestableStandardServer server = CreateServer();
|
|
ApplicationConfiguration configuration = CreateConfiguration(
|
|
"opc.tcp://localhost:62541/TestServer");
|
|
var registry = new DefaultTransportBindingRegistry();
|
|
var factory = new FakeTcpListenerFactory();
|
|
registry.RegisterListenerFactory(factory);
|
|
|
|
await server.InitializeAndDiscardServiceHostsAsync(configuration, registry)
|
|
.ConfigureAwait(false);
|
|
|
|
Assert.That(factory.CreateServiceHostCallCount, Is.EqualTo(1));
|
|
}
|
|
|
|
private static ApplicationConfiguration CreateConfiguration(string endpointUrl)
|
|
{
|
|
return new ApplicationConfiguration
|
|
{
|
|
ApplicationName = "TestServer",
|
|
ApplicationUri = "urn:localhost:UA:TestServer",
|
|
ProductUri = "uri:opcfoundation.org:TestServer",
|
|
ServerConfiguration = new ServerConfiguration
|
|
{
|
|
BaseAddresses = [endpointUrl]
|
|
}
|
|
};
|
|
}
|
|
|
|
private sealed class FakeTcpListenerFactory : ITransportListenerFactory
|
|
{
|
|
public string UriScheme => Utils.UriSchemeOpcTcp;
|
|
|
|
public int CreateServiceHostCallCount { get; private set; }
|
|
|
|
public ITransportListener Create(ITelemetryContext telemetry)
|
|
{
|
|
throw new NotSupportedException();
|
|
}
|
|
|
|
public ValueTask<List<EndpointDescription>> CreateServiceHostAsync(
|
|
ServerBase serverBase,
|
|
IDictionary<string, ServiceHost> hosts,
|
|
ApplicationConfiguration configuration,
|
|
ArrayOf<string> baseAddresses,
|
|
ApplicationDescription serverDescription,
|
|
ArrayOf<ServerSecurityPolicy> securityPolicies,
|
|
ICertificateRegistry serverCertificates,
|
|
ICertificateValidatorEx clientCertificateValidator,
|
|
CancellationToken ct = default)
|
|
{
|
|
CreateServiceHostCallCount++;
|
|
return new ValueTask<List<EndpointDescription>>([]);
|
|
}
|
|
}
|
|
}
|
|
}
|