mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-22 01:26:20 +08:00
9b034f8291
# Description Nullable reference types are now enabled once, in `common.props`, and every project in the repository builds warning-clean with them. **Configuration** - `<Nullable>enable</Nullable>` lives in `common.props`. All per-project `<Nullable>` settings are removed: 178 project files (enable, annotations, and the `disable` in the three Aot test projects). - 419 per-file `#nullable enable`/`restore` directives and the 4 `#nullable disable` directives are removed. - Kept on purpose: - The 5 `<auto-generated>` schema files (`UANodeSet.cs`, `UA Type Dictionary.cs`, `OPCBinarySchema.cs`, `SecuredApplication.cs`, `PInvokeHelper.cs`) keep `#nullable enable`. The compiler ignores the project setting in generated code, so the directive is required there. - `tests/Opc.Ua.Interop.LegacyPeer` keeps `disable`. It builds against the 1.5.378 packages and does not import the repository props. - `docs/DeveloperGuide.md` and `.github/copilot-instructions.md` now describe the central setting. **Migration** (the 43 unset and 13 annotations-only projects, roughly 10k diagnostics) - **Production code: annotated at the declarations.** Covers `tools/Opc.Ua.SourceGeneration.Core`, `src/Opc.Ua.Lds.Server` and the LDS sample. - Members, parameters and returns are nullable where null actually flows. - Null-check helpers carry `[NotNullWhen]` (`IsNull`, `IsValidXmlName`, `IsMethodTypeDesign`). - `CoreUtils.Format`, `Utils.Format`, `ITemplateWriter.WriteLine` take `params object?[]`. `Template.AddReplacement` takes `string?` (rendering already skips null replacements). - `IRegisteredServerStore.RegisterAsync` returns `Task<RegistrationEntry?>`: it returns null when a registration is removed. - Dereferences backed by an invariant (for example, the validated model) use `!`, so runtime behaviour is unchanged. - **Behavioural changes in production code** (small, deliberate): - `NodeSetToModelDesign` throws `InvalidOperationException` when the file is not a NodeSet. Before, it threw a NullReferenceException on the next line. - `ModelDesignValidator` starts its lookup tables empty instead of null. A redundant `if (m_nodes == null) return;` guard is gone. - **Test code: minimal annotations.** - `?` on values that can be null, `!` where the test asserts non-null. - `= null!` for fixture members assigned in SetUp. - `TryGetStructure<T>(out T? x)` where the inferred `T?` broke the `IEncodeable` constraint. - A few redundant `== null` checks on structs (`NodeId`, `ArrayOf<T>`) folded into the adjacent `IsNull`/`Count` checks. **Notes for reviewers** - The diff is large (~1,500 files) but almost entirely mechanical: `?`, `!` and directive removal. The non-annotation changes are listed above. - Known follow-up, not done here: `IModelDesign.Namespaces/TargetNamespace/NamespaceUris` stay non-nullable even though the validator returns null before it loads a dictionary. Making them nullable would ripple through every generator call site. ## Related Issues - Fixes #4439 ## Checklist - [ ] 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. - [ ] 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. - [ ] I have addressed **all** PR feedback received. The "no new warnings" box covers net10.0 and net48: full non-incremental builds of `UA.slnx`, `tools/SourceGeneration.slnx`, `tools/MigrationAnalyzer.slnx` and the Aot test projects finished with 0 warnings and 0 errors under the repository's warnings-as-errors settings. net9.0 and net8.0 also had no nullable diagnostics, but they were not rebuilt after the final fixes. Tests have not been run locally yet. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1591 lines
64 KiB
C#
1591 lines
64 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.Threading;
|
||
using System.Threading.Tasks;
|
||
using NUnit.Framework;
|
||
using Opc.Ua.Client;
|
||
using Opc.Ua.Client.TestFramework;
|
||
|
||
namespace Opc.Ua.Sessions.Tests
|
||
{
|
||
/// <summary>
|
||
/// compliance tests for Session Service Set – base session
|
||
/// lifecycle operations.
|
||
/// </summary>
|
||
[TestFixture]
|
||
[Category("Conformance")]
|
||
[Category("SessionBase")]
|
||
public class SessionBaseTests : TestFixture
|
||
{
|
||
[Test]
|
||
public void CreateSessionWithSpecificName()
|
||
{
|
||
Assert.That(Session.SessionName, Is.Not.Null.And.Not.Empty);
|
||
}
|
||
|
||
[Test]
|
||
public void CreateSessionWithRequestedTimeout()
|
||
{
|
||
Assert.That(Session.SessionTimeout, Is.GreaterThan(0));
|
||
}
|
||
|
||
[Test]
|
||
public async Task ReadSessionDiagnosticsArrayFindOurSessionAsync()
|
||
{
|
||
DataValue result = await ReadNodeValueAsync(
|
||
VariableIds
|
||
.Server_ServerDiagnostics_ServerDiagnosticsSummary_CurrentSessionCount)
|
||
.ConfigureAwait(false);
|
||
Assert.That(StatusCode.IsGood(result.StatusCode), Is.True);
|
||
uint count = result.WrappedValue.GetUInt32();
|
||
Assert.That(count, Is.GreaterThanOrEqualTo(1u));
|
||
}
|
||
|
||
[Test]
|
||
public async Task ActivateSessionWithAnonymousIdentityAsync()
|
||
{
|
||
ISession additionalSession = await ClientFixture
|
||
.ConnectAsync(ServerUrl, SecurityPolicies.None)
|
||
.ConfigureAwait(false);
|
||
try
|
||
{
|
||
Assert.That(additionalSession.Connected, Is.True);
|
||
Assert.That(additionalSession.SessionId, Is.Not.Null);
|
||
}
|
||
finally
|
||
{
|
||
await additionalSession.CloseAsync(5000, true)
|
||
.ConfigureAwait(false);
|
||
additionalSession.Dispose();
|
||
}
|
||
}
|
||
|
||
[Test]
|
||
public async Task CloseSessionWithDeleteSubscriptionsTrueAsync()
|
||
{
|
||
ISession additionalSession = await ClientFixture
|
||
.ConnectAsync(ServerUrl, SecurityPolicies.None)
|
||
.ConfigureAwait(false);
|
||
|
||
Assert.That(additionalSession.Connected, Is.True);
|
||
await additionalSession.CloseAsync(5000, true)
|
||
.ConfigureAwait(false);
|
||
additionalSession.Dispose();
|
||
}
|
||
|
||
[Test]
|
||
public async Task CloseSessionWithDeleteSubscriptionsFalseAsync()
|
||
{
|
||
ISession additionalSession = await ClientFixture
|
||
.ConnectAsync(ServerUrl, SecurityPolicies.None)
|
||
.ConfigureAwait(false);
|
||
|
||
Assert.That(additionalSession.Connected, Is.True);
|
||
await additionalSession.CloseAsync(5000, false)
|
||
.ConfigureAwait(false);
|
||
additionalSession.Dispose();
|
||
}
|
||
|
||
[Test]
|
||
public async Task SessionKeepaliveVerifySessionStaysActiveAsync()
|
||
{
|
||
Assert.That(Session.Connected, Is.True);
|
||
await Task.Delay(500).ConfigureAwait(false);
|
||
Assert.That(Session.Connected, Is.True);
|
||
}
|
||
|
||
[Test]
|
||
public void VerifySessionEndpointDescription()
|
||
{
|
||
Assert.That(Session.Endpoint, Is.Not.Null);
|
||
Assert.That(
|
||
Session.Endpoint.EndpointUrl, Is.Not.Null.And.Not.Empty);
|
||
}
|
||
|
||
[Test]
|
||
public void VerifySessionServerCertificate()
|
||
{
|
||
Assert.That(Session.Endpoint, Is.Not.Null);
|
||
// With SecurityMode None the certificate may or may not be set.
|
||
// Just verify the endpoint itself is accessible.
|
||
Assert.That(
|
||
Session.Endpoint.SecurityMode,
|
||
Is.EqualTo(MessageSecurityMode.None));
|
||
}
|
||
|
||
[Test]
|
||
public void ReadMaxResponseMessageSize()
|
||
{
|
||
Assert.That(Session.Endpoint, Is.Not.Null);
|
||
Assert.That(
|
||
Session.Endpoint.TransportProfileUri,
|
||
Is.Not.Null.And.Not.Empty);
|
||
}
|
||
|
||
[Test]
|
||
public async Task CreateAndVerifyMultipleSessionsAsync()
|
||
{
|
||
ISession session1 = await ClientFixture
|
||
.ConnectAsync(ServerUrl, SecurityPolicies.None)
|
||
.ConfigureAwait(false);
|
||
ISession session2 = await ClientFixture
|
||
.ConnectAsync(ServerUrl, SecurityPolicies.None)
|
||
.ConfigureAwait(false);
|
||
|
||
try
|
||
{
|
||
Assert.That(session1.SessionId, Is.Not.Null);
|
||
Assert.That(session2.SessionId, Is.Not.Null);
|
||
Assert.That(
|
||
session1.SessionId,
|
||
Is.Not.EqualTo(session2.SessionId));
|
||
}
|
||
finally
|
||
{
|
||
await session1.CloseAsync(5000, true)
|
||
.ConfigureAwait(false);
|
||
session1.Dispose();
|
||
await session2.CloseAsync(5000, true)
|
||
.ConfigureAwait(false);
|
||
session2.Dispose();
|
||
}
|
||
}
|
||
|
||
[Test]
|
||
public void SessionIdentityToken()
|
||
{
|
||
Assert.That(Session.Identity, Is.Not.Null);
|
||
}
|
||
|
||
[Test]
|
||
public async Task CreateSessionWithEmptyNameAsync()
|
||
{
|
||
ISession additionalSession = await ClientFixture
|
||
.ConnectAsync(ServerUrl, SecurityPolicies.None)
|
||
.ConfigureAwait(false);
|
||
try
|
||
{
|
||
Assert.That(additionalSession.SessionId, Is.Not.Null);
|
||
}
|
||
finally
|
||
{
|
||
await additionalSession.CloseAsync(5000, true)
|
||
.ConfigureAwait(false);
|
||
additionalSession.Dispose();
|
||
}
|
||
}
|
||
|
||
[Test]
|
||
public async Task CreateSessionWithLongNameAsync()
|
||
{
|
||
ISession additionalSession = await ClientFixture
|
||
.ConnectAsync(ServerUrl, SecurityPolicies.None)
|
||
.ConfigureAwait(false);
|
||
try
|
||
{
|
||
Assert.That(additionalSession.Connected, Is.True);
|
||
Assert.That(additionalSession.SessionId, Is.Not.Null);
|
||
}
|
||
finally
|
||
{
|
||
await additionalSession.CloseAsync(5000, true)
|
||
.ConfigureAwait(false);
|
||
additionalSession.Dispose();
|
||
}
|
||
}
|
||
|
||
[Test]
|
||
public void SessionTimeoutIsRevisedByServer()
|
||
{
|
||
Assert.That(Session.SessionTimeout, Is.GreaterThan(0));
|
||
}
|
||
|
||
[Test]
|
||
public async Task ReadSessionDiagnosticsCurrentSubscriptionsCountAsync()
|
||
{
|
||
DataValue result = await ReadNodeValueAsync(
|
||
VariableIds
|
||
.Server_ServerDiagnostics_ServerDiagnosticsSummary_CurrentSubscriptionCount)
|
||
.ConfigureAwait(false);
|
||
Assert.That(StatusCode.IsGood(result.StatusCode), Is.True);
|
||
int count = result.WrappedValue.GetInt32();
|
||
Assert.That(count, Is.GreaterThanOrEqualTo(0));
|
||
}
|
||
|
||
[Test]
|
||
public async Task ReadSessionDiagnosticsCurrentSessionCountAsync()
|
||
{
|
||
DataValue result = await ReadNodeValueAsync(
|
||
VariableIds
|
||
.Server_ServerDiagnostics_ServerDiagnosticsSummary_CurrentSessionCount)
|
||
.ConfigureAwait(false);
|
||
Assert.That(StatusCode.IsGood(result.StatusCode), Is.True);
|
||
uint count = result.WrappedValue.GetUInt32();
|
||
Assert.That(count, Is.GreaterThanOrEqualTo(1u));
|
||
}
|
||
|
||
[Test]
|
||
public async Task ReadSessionSecurityDiagnosticsAsync()
|
||
{
|
||
// OPC UA well-known NodeId 2162 = SecurityRejectedSessionCount
|
||
DataValue result = await ReadNodeValueAsync(
|
||
new NodeId(2162))
|
||
.ConfigureAwait(false);
|
||
if (!StatusCode.IsGood(result.StatusCode))
|
||
{
|
||
Assert.Ignore(
|
||
"Server does not support SecurityRejectedSessionCount diagnostic.");
|
||
}
|
||
|
||
int count = result.WrappedValue.GetInt32();
|
||
Assert.That(count, Is.GreaterThanOrEqualTo(0));
|
||
}
|
||
|
||
[Test]
|
||
public async Task ActivateMultipleTimesOnSameSessionAsync()
|
||
{
|
||
ISession additionalSession = await ClientFixture
|
||
.ConnectAsync(ServerUrl, SecurityPolicies.None)
|
||
.ConfigureAwait(false);
|
||
try
|
||
{
|
||
NodeId originalId = additionalSession.SessionId;
|
||
Assert.That(originalId, Is.Not.Null);
|
||
|
||
DataValue result = await ReadNodeValueAsync(
|
||
VariableIds.Server_ServerStatus_State)
|
||
.ConfigureAwait(false);
|
||
Assert.That(StatusCode.IsGood(result.StatusCode), Is.True);
|
||
|
||
Assert.That(
|
||
additionalSession.SessionId,
|
||
Is.EqualTo(originalId));
|
||
}
|
||
finally
|
||
{
|
||
await additionalSession.CloseAsync(5000, true)
|
||
.ConfigureAwait(false);
|
||
additionalSession.Dispose();
|
||
}
|
||
}
|
||
|
||
[Test]
|
||
public async Task CreateSessionVerifySessionIdIsUniqueAsync()
|
||
{
|
||
ISession session1 = await ClientFixture
|
||
.ConnectAsync(ServerUrl, SecurityPolicies.None)
|
||
.ConfigureAwait(false);
|
||
ISession session2 = await ClientFixture
|
||
.ConnectAsync(ServerUrl, SecurityPolicies.None)
|
||
.ConfigureAwait(false);
|
||
try
|
||
{
|
||
Assert.That(session1.SessionId, Is.Not.Null);
|
||
Assert.That(session2.SessionId, Is.Not.Null);
|
||
Assert.That(
|
||
session1.SessionId,
|
||
Is.Not.EqualTo(session2.SessionId));
|
||
}
|
||
finally
|
||
{
|
||
await session1.CloseAsync(5000, true)
|
||
.ConfigureAwait(false);
|
||
session1.Dispose();
|
||
await session2.CloseAsync(5000, true)
|
||
.ConfigureAwait(false);
|
||
session2.Dispose();
|
||
}
|
||
}
|
||
|
||
[Test]
|
||
public void VerifySessionPreferredLocales()
|
||
{
|
||
Assert.That(Session, Is.Not.Null);
|
||
Assert.That(Session.Connected, Is.True);
|
||
}
|
||
|
||
[Test]
|
||
public async Task ReadSessionDiagnosticsCumulatedSessionCountAsync()
|
||
{
|
||
DataValue result = await ReadNodeValueAsync(
|
||
VariableIds
|
||
.Server_ServerDiagnostics_ServerDiagnosticsSummary_CumulatedSessionCount)
|
||
.ConfigureAwait(false);
|
||
Assert.That(StatusCode.IsGood(result.StatusCode), Is.True);
|
||
uint count = result.WrappedValue.GetUInt32();
|
||
Assert.That(count, Is.GreaterThanOrEqualTo(1u));
|
||
}
|
||
|
||
[Test]
|
||
public async Task CreateSessionAndReadServerStateAsync()
|
||
{
|
||
ISession additionalSession = await ClientFixture
|
||
.ConnectAsync(ServerUrl, SecurityPolicies.None)
|
||
.ConfigureAwait(false);
|
||
try
|
||
{
|
||
Assert.That(additionalSession.Connected, Is.True);
|
||
|
||
DataValue result = await ReadNodeValueAsync(
|
||
VariableIds.Server_ServerStatus_State)
|
||
.ConfigureAwait(false);
|
||
Assert.That(StatusCode.IsGood(result.StatusCode), Is.True);
|
||
int state = result.WrappedValue.GetInt32();
|
||
Assert.That(
|
||
state,
|
||
Is.EqualTo((int)ServerState.Running));
|
||
}
|
||
finally
|
||
{
|
||
await additionalSession.CloseAsync(5000, true)
|
||
.ConfigureAwait(false);
|
||
additionalSession.Dispose();
|
||
}
|
||
}
|
||
|
||
[Test]
|
||
public async Task CloseSessionAndVerifyDisconnectedAsync()
|
||
{
|
||
ISession additionalSession = await ClientFixture
|
||
.ConnectAsync(ServerUrl, SecurityPolicies.None)
|
||
.ConfigureAwait(false);
|
||
|
||
Assert.That(additionalSession.Connected, Is.True);
|
||
await additionalSession.CloseAsync(5000, true)
|
||
.ConfigureAwait(false);
|
||
Assert.That(additionalSession.Connected, Is.False);
|
||
additionalSession.Dispose();
|
||
}
|
||
|
||
[Test]
|
||
public void SessionEndpointHasTransportProfileUri()
|
||
{
|
||
Assert.That(Session.Endpoint, Is.Not.Null);
|
||
Assert.That(
|
||
Session.Endpoint.TransportProfileUri,
|
||
Is.Not.Null.And.Not.Empty);
|
||
}
|
||
|
||
[Test]
|
||
public async Task ReadRejectedSessionCountAsync()
|
||
{
|
||
// OPC UA well-known NodeId 2154 = RejectedSessionCount
|
||
DataValue result = await ReadNodeValueAsync(
|
||
new NodeId(2154))
|
||
.ConfigureAwait(false);
|
||
Assert.That(StatusCode.IsGood(result.StatusCode), Is.True);
|
||
int count = result.WrappedValue.GetInt32();
|
||
Assert.That(count, Is.GreaterThanOrEqualTo(0));
|
||
}
|
||
|
||
[Test]
|
||
public async Task ReadSessionDiagnosticsSecurityRejectedRequestsCountAsync()
|
||
{
|
||
// OPC UA well-known NodeId 2163 = SecurityRejectedRequestsCount
|
||
DataValue result = await ReadNodeValueAsync(
|
||
new NodeId(2163))
|
||
.ConfigureAwait(false);
|
||
Assert.That(StatusCode.IsGood(result.StatusCode), Is.True);
|
||
int count = result.WrappedValue.GetInt32();
|
||
Assert.That(count, Is.GreaterThanOrEqualTo(0));
|
||
}
|
||
|
||
private async Task<DataValue> ReadNodeValueAsync(NodeId nodeId)
|
||
{
|
||
ReadResponse response = await Session.ReadAsync(
|
||
null, 0, TimestampsToReturn.Both,
|
||
new ReadValueId[]
|
||
{
|
||
new() {
|
||
NodeId = nodeId,
|
||
AttributeId = Attributes.Value
|
||
}
|
||
}.ToArrayOf(),
|
||
CancellationToken.None).ConfigureAwait(false);
|
||
|
||
Assert.That(response.Results.Count, Is.EqualTo(1));
|
||
return response.Results[0];
|
||
}
|
||
|
||
[Description("Invoke CreateSession specifying a RequestedSessionTimeout of 0. We expect the RevisedSessionTimeout != 0. */")]
|
||
[Test]
|
||
public async Task CreateSessionWithZeroTimeoutReturnsRevisedTimeoutAsync()
|
||
{
|
||
ReadResponse readResponse = await Session.ReadAsync(
|
||
null, 0, TimestampsToReturn.Both,
|
||
new ReadValueId[]
|
||
{
|
||
new() { NodeId = VariableIds.Server_ServerStatus, AttributeId = Attributes.Value }
|
||
}.ToArrayOf(),
|
||
CancellationToken.None).ConfigureAwait(false);
|
||
|
||
Assert.That(readResponse.Results.Count, Is.EqualTo(1));
|
||
Assert.That(StatusCode.IsGood(readResponse.Results[0].StatusCode), Is.True);
|
||
}
|
||
|
||
[Description("CreateSession with default parameters, except for a small timeout of 10 seconds. Activate the session and stall (do not use) the session for a period GREATER than the timeout perio")]
|
||
[Test]
|
||
public async Task CreateSessionStallsBeyondTimeoutPeriodAsync()
|
||
{
|
||
ReadResponse readResponse = await Session.ReadAsync(
|
||
null, 0, TimestampsToReturn.Both,
|
||
new ReadValueId[]
|
||
{
|
||
new() { NodeId = VariableIds.Server_ServerStatus, AttributeId = Attributes.Value }
|
||
}.ToArrayOf(),
|
||
CancellationToken.None).ConfigureAwait(false);
|
||
|
||
Assert.That(readResponse.Results.Count, Is.EqualTo(1));
|
||
Assert.That(StatusCode.IsGood(readResponse.Results[0].StatusCode), Is.True);
|
||
}
|
||
|
||
[Description("Invoke CreateSession and then check if the session appears within the server diagnostics. This script must first read the servers profile to see if diagnostics are supported and if")]
|
||
[Test]
|
||
public async Task CreateSessionAppearsInServerDiagnosticsAsync()
|
||
{
|
||
ReadResponse readResponse = await Session.ReadAsync(
|
||
null, 0, TimestampsToReturn.Both,
|
||
new ReadValueId[]
|
||
{
|
||
new() { NodeId = VariableIds.Server_ServerStatus, AttributeId = Attributes.Value }
|
||
}.ToArrayOf(),
|
||
CancellationToken.None).ConfigureAwait(false);
|
||
|
||
Assert.That(readResponse.Results.Count, Is.EqualTo(1));
|
||
Assert.That(StatusCode.IsGood(readResponse.Results[0].StatusCode), Is.True);
|
||
}
|
||
|
||
[Description("activate a session using default parameters. */")]
|
||
[Test]
|
||
public async Task ActivateSessionWithDefaultParametersAsync()
|
||
{
|
||
ReadResponse readResponse = await Session.ReadAsync(
|
||
null, 0, TimestampsToReturn.Both,
|
||
new ReadValueId[]
|
||
{
|
||
new() { NodeId = VariableIds.Server_ServerStatus, AttributeId = Attributes.Value }
|
||
}.ToArrayOf(),
|
||
CancellationToken.None).ConfigureAwait(false);
|
||
|
||
Assert.That(readResponse.Results.Count, Is.EqualTo(1));
|
||
Assert.That(StatusCode.IsGood(readResponse.Results[0].StatusCode), Is.True);
|
||
}
|
||
|
||
[Description("Specify numerous localeIds supported by the server, in a ranked order. */")]
|
||
[Test]
|
||
public async Task CreateSessionWithRankedLocaleIdsAsync()
|
||
{
|
||
ReadResponse readResponse = await Session.ReadAsync(
|
||
null, 0, TimestampsToReturn.Both,
|
||
new ReadValueId[]
|
||
{
|
||
new() { NodeId = VariableIds.Server_ServerStatus, AttributeId = Attributes.Value }
|
||
}.ToArrayOf(),
|
||
CancellationToken.None).ConfigureAwait(false);
|
||
|
||
Assert.That(readResponse.Results.Count, Is.EqualTo(1));
|
||
Assert.That(StatusCode.IsGood(readResponse.Results[0].StatusCode), Is.True);
|
||
}
|
||
|
||
[Description("activate a session that has been transferred to another channel. */")]
|
||
[Test]
|
||
public async Task ActivateSessionTransferredToAnotherChannelAsync()
|
||
{
|
||
ReadResponse readResponse = await Session.ReadAsync(
|
||
null, 0, TimestampsToReturn.Both,
|
||
new ReadValueId[]
|
||
{
|
||
new() { NodeId = VariableIds.Server_ServerStatus, AttributeId = Attributes.Value }
|
||
}.ToArrayOf(),
|
||
CancellationToken.None).ConfigureAwait(false);
|
||
|
||
Assert.That(readResponse.Results.Count, Is.EqualTo(1));
|
||
Assert.That(StatusCode.IsGood(readResponse.Results[0].StatusCode), Is.True);
|
||
}
|
||
|
||
[Description("provide NO software certificates. This used to be a problem, but UA 1.02 changed this behavior. */")]
|
||
[Test]
|
||
public async Task CreateSessionWithNoSoftwareCertificatesAsync()
|
||
{
|
||
ReadResponse readResponse = await Session.ReadAsync(
|
||
null, 0, TimestampsToReturn.Both,
|
||
new ReadValueId[]
|
||
{
|
||
new() { NodeId = VariableIds.Server_ServerStatus, AttributeId = Attributes.Value }
|
||
}.ToArrayOf(),
|
||
CancellationToken.None).ConfigureAwait(false);
|
||
|
||
Assert.That(readResponse.Results.Count, Is.EqualTo(1));
|
||
Assert.That(StatusCode.IsGood(readResponse.Results[0].StatusCode), Is.True);
|
||
}
|
||
|
||
[Description("CloseSession using default parameters. This test works by first opening a session (default parameters) and then closes it. */")]
|
||
[Test]
|
||
public async Task CloseSessionWithDefaultParametersAsync()
|
||
{
|
||
ReadResponse readResponse = await Session.ReadAsync(
|
||
null, 0, TimestampsToReturn.Both,
|
||
new ReadValueId[]
|
||
{
|
||
new() { NodeId = VariableIds.Server_ServerStatus, AttributeId = Attributes.Value }
|
||
}.ToArrayOf(),
|
||
CancellationToken.None).ConfigureAwait(false);
|
||
|
||
Assert.That(readResponse.Results.Count, Is.EqualTo(1));
|
||
Assert.That(StatusCode.IsGood(readResponse.Results[0].StatusCode), Is.True);
|
||
}
|
||
|
||
[Description("provide NO software certificates. This used to be a problem, but UA 1.02 changed this behavior. */")]
|
||
[Test]
|
||
public async Task ActivateSessionWithNoSoftwareCertificatesAsync()
|
||
{
|
||
ReadResponse readResponse = await Session.ReadAsync(
|
||
null, 0, TimestampsToReturn.Both,
|
||
new ReadValueId[]
|
||
{
|
||
new() { NodeId = VariableIds.Server_ServerStatus, AttributeId = Attributes.Value }
|
||
}.ToArrayOf(),
|
||
CancellationToken.None).ConfigureAwait(false);
|
||
|
||
Assert.That(readResponse.Results.Count, Is.EqualTo(1));
|
||
Assert.That(StatusCode.IsGood(readResponse.Results[0].StatusCode), Is.True);
|
||
}
|
||
|
||
[Description("Using SecurityPolicy None/anonymous, create a session while specifying a not-trusted certificate.")]
|
||
[Test]
|
||
public async Task CreateSessionWithUntrustedCertificateAndNoneSecurityAsync()
|
||
{
|
||
ReadResponse readResponse = await Session.ReadAsync(
|
||
null, 0, TimestampsToReturn.Both,
|
||
new ReadValueId[]
|
||
{
|
||
new() { NodeId = VariableIds.Server_ServerStatus, AttributeId = Attributes.Value }
|
||
}.ToArrayOf(),
|
||
CancellationToken.None).ConfigureAwait(false);
|
||
|
||
Assert.That(readResponse.Results.Count, Is.EqualTo(1));
|
||
Assert.That(StatusCode.IsGood(readResponse.Results[0].StatusCode), Is.True);
|
||
}
|
||
|
||
[Description("Create a session without specifying a SessionName (legal, but some servers crash) */")]
|
||
[Test]
|
||
public async Task CreateSessionWithoutSessionNameAsync()
|
||
{
|
||
ReadResponse readResponse = await Session.ReadAsync(
|
||
null, 0, TimestampsToReturn.Both,
|
||
new ReadValueId[]
|
||
{
|
||
new() { NodeId = VariableIds.Server_ServerStatus, AttributeId = Attributes.Value }
|
||
}.ToArrayOf(),
|
||
CancellationToken.None).ConfigureAwait(false);
|
||
|
||
Assert.That(readResponse.Results.Count, Is.EqualTo(1));
|
||
Assert.That(StatusCode.IsGood(readResponse.Results[0].StatusCode), Is.True);
|
||
}
|
||
|
||
[Description("Over a non-secure channel; call ActivateSession() specifying an empty ClientSignature. */")]
|
||
[Test]
|
||
public async Task ActivateSessionWithEmptyClientSignatureOnNonSecureChannelAsync()
|
||
{
|
||
ReadResponse readResponse = await Session.ReadAsync(
|
||
null, 0, TimestampsToReturn.Both,
|
||
new ReadValueId[]
|
||
{
|
||
new() { NodeId = VariableIds.Server_ServerStatus, AttributeId = Attributes.Value }
|
||
}.ToArrayOf(),
|
||
CancellationToken.None).ConfigureAwait(false);
|
||
|
||
Assert.That(readResponse.Results.Count, Is.EqualTo(1));
|
||
Assert.That(StatusCode.IsGood(readResponse.Results[0].StatusCode), Is.True);
|
||
}
|
||
|
||
[Description("CreateSession – injects service result Bad_SecureChannelIdInvalid in the response.")]
|
||
[Test]
|
||
public Task CreateSessionWithInjectedBadSecureChannelIdInvalidAsync()
|
||
{
|
||
return AssertCreateSessionInjectsServiceResultAsync(StatusCodes.BadSecureChannelIdInvalid);
|
||
}
|
||
|
||
private Task AssertCreateSessionInjectsServiceResultAsync(StatusCode injected)
|
||
{
|
||
// Session.OpenAsync retries CreateSession without the client
|
||
// certificate when the first attempt fails, so a one-shot
|
||
// expectation would only mutate the first response and the
|
||
// retry would succeed. Use a recurring expectation so both
|
||
// attempts return the injected error.
|
||
using IDisposable expectation = MockController.WhenRequest<CreateSessionRequest, CreateSessionResponse>(
|
||
(req, resp) => resp.ResponseHeader.ServiceResult = injected);
|
||
|
||
ServiceResultException ex = Assert.ThrowsAsync<ServiceResultException>(
|
||
async () => await OpenAuxSessionAsync().ConfigureAwait(false));
|
||
Assert.That(ex.StatusCode, Is.EqualTo(injected));
|
||
return Task.CompletedTask;
|
||
}
|
||
|
||
[Description("CreateSession – injects service result Bad_NonceInvalid in the response.")]
|
||
[Test]
|
||
public Task CreateSessionWithInjectedBadNonceInvalidAsync()
|
||
{
|
||
return AssertCreateSessionInjectsServiceResultAsync(StatusCodes.BadNonceInvalid);
|
||
}
|
||
|
||
[Description("CreateSession – injects service result Bad_SecurityChecksFailed in the response.")]
|
||
[Test]
|
||
public Task CreateSessionWithInjectedBadSecurityChecksFailedAsync()
|
||
{
|
||
return AssertCreateSessionInjectsServiceResultAsync(StatusCodes.BadSecurityChecksFailed);
|
||
}
|
||
|
||
[Description("CreateSession – injects service result Bad_CertificateTimeInvalid in the response.")]
|
||
[Test]
|
||
public Task CreateSessionWithInjectedBadCertificateTimeInvalidAsync()
|
||
{
|
||
return AssertCreateSessionInjectsServiceResultAsync(StatusCodes.BadCertificateTimeInvalid);
|
||
}
|
||
|
||
[Description("CreateSession – injects service result Bad_CertificateIssuerTimeInvalid in the response.")]
|
||
[Test]
|
||
public Task CreateSessionWithInjectedBadCertificateIssuerTimeInvalidAsync()
|
||
{
|
||
return AssertCreateSessionInjectsServiceResultAsync(StatusCodes.BadCertificateIssuerTimeInvalid);
|
||
}
|
||
|
||
[Description("CreateSession – injects service result Bad_CertificateHostnameInvalid in the response.")]
|
||
[Test]
|
||
public Task CreateSessionWithInjectedBadCertificateHostnameInvalidAsync()
|
||
{
|
||
return AssertCreateSessionInjectsServiceResultAsync(StatusCodes.BadCertificateHostNameInvalid);
|
||
}
|
||
|
||
[Description("CreateSession – the SessionId returned by the server is null for the second session.")]
|
||
[Test]
|
||
public Task CreateSessionWithInjectedNullSessionIdAsync()
|
||
{
|
||
// A null SessionId in the response makes ActivateSession fail
|
||
// because the client uses SessionId to identify the session.
|
||
using IDisposable expectation = MockController.WhenRequest<CreateSessionRequest, CreateSessionResponse>(
|
||
(req, resp) => resp.SessionId = NodeId.Null);
|
||
|
||
Assert.ThrowsAsync<ServiceResultException>(
|
||
async () => await OpenAuxSessionAsync().ConfigureAwait(false));
|
||
return Task.CompletedTask;
|
||
}
|
||
|
||
[Description("CreateSession – the server returns the same AuthenticationToken for two distinct sessions.")]
|
||
[Test]
|
||
public async Task CreateSessionWithInjectedDuplicateAuthenticationTokenAsync()
|
||
{
|
||
// Capture the AuthenticationToken from the first
|
||
// CreateSession response, then on the second response
|
||
// overwrite it with the captured value.
|
||
NodeId firstToken = NodeId.Null;
|
||
using (MockController.WhenRequest<CreateSessionRequest, CreateSessionResponse>(
|
||
(req, resp) =>
|
||
{
|
||
if (firstToken.IsNull)
|
||
{
|
||
firstToken = resp.AuthenticationToken;
|
||
}
|
||
else
|
||
{
|
||
resp.AuthenticationToken = firstToken;
|
||
}
|
||
}))
|
||
{
|
||
ISession first = await OpenAuxSessionAsync().ConfigureAwait(false);
|
||
ISession? second = null;
|
||
try
|
||
{
|
||
Assert.That(firstToken.IsNull, Is.False);
|
||
|
||
try
|
||
{
|
||
second = await OpenAuxSessionAsync().ConfigureAwait(false);
|
||
Assert.That(second.Connected, Is.True);
|
||
}
|
||
catch (ServiceResultException)
|
||
{
|
||
// Acceptable: the server may reject the
|
||
// duplicate token during ActivateSession.
|
||
}
|
||
}
|
||
finally
|
||
{
|
||
foreach (ISession? s in new[] { first, second })
|
||
{
|
||
if (s == null)
|
||
{
|
||
continue;
|
||
}
|
||
try
|
||
{
|
||
await s.CloseAsync(5000, true, CancellationToken.None).ConfigureAwait(false);
|
||
}
|
||
catch
|
||
{
|
||
// best effort
|
||
}
|
||
s.Dispose();
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
[Description("CreateSession – the server revises the session timeout to (client request * 10).")]
|
||
[Test]
|
||
public Task CreateSessionWithInjectedExcessiveRevisedSessionTimeoutAsync()
|
||
{
|
||
return AssertCreateSessionAcceptsTimeoutMutationAsync(
|
||
mutate: (req, resp) => resp.RevisedSessionTimeout = req.RequestedSessionTimeout * 10.0);
|
||
}
|
||
|
||
[Description("CreateSession – the server revises the session timeout to an unreasonably low value (1 second).")]
|
||
[Test]
|
||
public Task CreateSessionWithInjectedTooLowRevisedSessionTimeoutAsync()
|
||
{
|
||
return AssertCreateSessionAcceptsTimeoutMutationAsync(
|
||
mutate: (req, resp) => resp.RevisedSessionTimeout = 1000.0);
|
||
}
|
||
|
||
private async Task AssertCreateSessionAcceptsTimeoutMutationAsync(
|
||
Action<CreateSessionRequest, CreateSessionResponse> mutate)
|
||
{
|
||
using IDisposable expectation = MockController.WhenRequest(mutate);
|
||
|
||
// The client must accept whatever RevisedSessionTimeout the
|
||
// server returns — assert the connection completes without
|
||
// throwing.
|
||
ISession session = await OpenAuxSessionAsync().ConfigureAwait(false);
|
||
try
|
||
{
|
||
Assert.That(session.Connected, Is.True);
|
||
}
|
||
finally
|
||
{
|
||
try
|
||
{
|
||
await session.CloseAsync(5000, true, CancellationToken.None).ConfigureAwait(false);
|
||
}
|
||
catch
|
||
{
|
||
// best effort
|
||
}
|
||
session.Dispose();
|
||
}
|
||
}
|
||
|
||
[Description("CreateSession – the server returns a ServerNonce that is less than 32 bytes long.")]
|
||
[Test]
|
||
public async Task CreateSessionWithInjectedShortServerNonceAsync()
|
||
{
|
||
using IDisposable expectation = MockController.WhenRequest<CreateSessionRequest, CreateSessionResponse>(
|
||
(req, resp) => resp.ServerNonce = new ByteString(new byte[8].AsMemory()));
|
||
|
||
// On a SecurityPolicy=None channel the client does not
|
||
// require a 32-byte nonce, so the connection completes.
|
||
// On a signed channel the client would reject the response.
|
||
ISession? session = null;
|
||
try
|
||
{
|
||
session = await OpenAuxSessionAsync().ConfigureAwait(false);
|
||
Assert.That(session.Connected, Is.True);
|
||
}
|
||
catch (ServiceResultException)
|
||
{
|
||
// Acceptable: client validated and rejected.
|
||
}
|
||
finally
|
||
{
|
||
if (session != null)
|
||
{
|
||
try
|
||
{
|
||
await session.CloseAsync(5000, true, CancellationToken.None).ConfigureAwait(false);
|
||
}
|
||
catch
|
||
{
|
||
// best effort
|
||
}
|
||
session.Dispose();
|
||
}
|
||
}
|
||
}
|
||
|
||
[Description("CreateSession – the EndpointDescriptions returned in the response differ from those obtained from the Discovery endpoint.")]
|
||
[Test]
|
||
public Task CreateSessionWithMismatchedEndpointDescriptionsAsync()
|
||
{
|
||
return AssertCreateSessionToleratesServerEndpointsMutationAsync(
|
||
mutate: (req, resp) =>
|
||
{
|
||
if (resp.ServerEndpoints != null! && resp.ServerEndpoints.Count > 0)
|
||
{
|
||
// Tweak the application URI on every endpoint so it
|
||
// no longer matches the one returned by Discovery.
|
||
foreach (EndpointDescription ep in resp.ServerEndpoints)
|
||
{
|
||
ep.Server?.ApplicationUri = "urn:mock:tampered";
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
[Description("CreateSession – the server returns an empty ServerEndpoints array.")]
|
||
[Test]
|
||
public Task CreateSessionWithInjectedEmptyServerEndpointsAsync()
|
||
{
|
||
return AssertCreateSessionToleratesServerEndpointsMutationAsync(
|
||
mutate: (req, resp) => resp.ServerEndpoints = Array.Empty<EndpointDescription>().ToArrayOf());
|
||
}
|
||
|
||
private async Task AssertCreateSessionToleratesServerEndpointsMutationAsync(
|
||
Action<CreateSessionRequest, CreateSessionResponse> mutate)
|
||
{
|
||
using IDisposable expectation = MockController.WhenRequest(mutate);
|
||
|
||
ISession? session = null;
|
||
try
|
||
{
|
||
session = await OpenAuxSessionAsync().ConfigureAwait(false);
|
||
Assert.That(session.Connected, Is.True);
|
||
}
|
||
catch (ServiceResultException)
|
||
{
|
||
// Acceptable: client may detect the mismatch and reject.
|
||
}
|
||
finally
|
||
{
|
||
if (session != null)
|
||
{
|
||
try
|
||
{
|
||
await session.CloseAsync(5000, true, CancellationToken.None).ConfigureAwait(false);
|
||
}
|
||
catch
|
||
{
|
||
// best effort
|
||
}
|
||
session.Dispose();
|
||
}
|
||
}
|
||
}
|
||
|
||
[Description("CreateSession – the server returns an empty ServerSoftwareCertificates array.")]
|
||
[Test]
|
||
public async Task CreateSessionWithInjectedEmptyServerSoftwareCertificatesAsync()
|
||
{
|
||
using IDisposable expectation = MockController.WhenRequest<CreateSessionRequest, CreateSessionResponse>(
|
||
(req, resp) => resp.ServerSoftwareCertificates = Array.Empty<SignedSoftwareCertificate>().ToArrayOf());
|
||
|
||
ISession session = await OpenAuxSessionAsync().ConfigureAwait(false);
|
||
try
|
||
{
|
||
Assert.That(session.Connected, Is.True);
|
||
}
|
||
finally
|
||
{
|
||
try
|
||
{
|
||
await session.CloseAsync(5000, true, CancellationToken.None).ConfigureAwait(false);
|
||
}
|
||
catch
|
||
{
|
||
// best effort
|
||
}
|
||
session.Dispose();
|
||
}
|
||
}
|
||
|
||
[Description("CreateSession – the server returns an empty ServerSignature.")]
|
||
[Test]
|
||
public async Task CreateSessionWithInjectedEmptyServerSignatureAsync()
|
||
{
|
||
using IDisposable expectation = MockController.WhenRequest<CreateSessionRequest, CreateSessionResponse>(
|
||
(req, resp) => resp.ServerSignature = new SignatureData());
|
||
|
||
ISession? session = null;
|
||
try
|
||
{
|
||
session = await OpenAuxSessionAsync().ConfigureAwait(false);
|
||
Assert.That(session.Connected, Is.True);
|
||
}
|
||
finally
|
||
{
|
||
if (session != null)
|
||
{
|
||
try
|
||
{
|
||
await session.CloseAsync(5000, true, CancellationToken.None).ConfigureAwait(false);
|
||
}
|
||
catch
|
||
{
|
||
// best effort
|
||
}
|
||
session.Dispose();
|
||
}
|
||
}
|
||
}
|
||
|
||
[Test]
|
||
public async Task SecuredCreateSessionRejectsEmptyServerSignatureAsync()
|
||
{
|
||
using IDisposable expectation = MockController
|
||
.ExpectNextResponse<CreateSessionResponse>(
|
||
response => response.ServerSignature = new SignatureData());
|
||
ConfiguredEndpoint endpoint = await CreateConfiguredEndpointAsync(
|
||
MessageSecurityMode.SignAndEncrypt).ConfigureAwait(false);
|
||
|
||
ServiceResultException exception = Assert.ThrowsAsync<ServiceResultException>(
|
||
async () => await ClientFixture.ConnectAsync(endpoint).ConfigureAwait(false))!;
|
||
|
||
Assert.That(
|
||
exception.StatusCode,
|
||
Is.EqualTo(StatusCodes.BadApplicationSignatureInvalid));
|
||
}
|
||
|
||
[TestCase(InvalidNonceKind.Null)]
|
||
[TestCase(InvalidNonceKind.Empty)]
|
||
[TestCase(InvalidNonceKind.Short)]
|
||
[TestCase(InvalidNonceKind.AllZero)]
|
||
[TestCase(InvalidNonceKind.TooLong)]
|
||
public async Task SecuredCreateSessionRejectsInvalidServerNonceAsync(
|
||
InvalidNonceKind nonceKind)
|
||
{
|
||
using IDisposable expectation = MockController
|
||
.ExpectNextResponse<CreateSessionResponse>(
|
||
response => response.ServerNonce = CreateInvalidNonce(nonceKind));
|
||
ConfiguredEndpoint endpoint = await CreateConfiguredEndpointAsync(
|
||
MessageSecurityMode.SignAndEncrypt).ConfigureAwait(false);
|
||
|
||
ServiceResultException exception = Assert.ThrowsAsync<ServiceResultException>(
|
||
async () => await ClientFixture.ConnectAsync(endpoint).ConfigureAwait(false))!;
|
||
|
||
Assert.That(exception.StatusCode, Is.EqualTo(StatusCodes.BadNonceInvalid));
|
||
}
|
||
|
||
[TestCase(InvalidNonceKind.Null, false)]
|
||
[TestCase(InvalidNonceKind.Empty, false)]
|
||
[TestCase(InvalidNonceKind.Short, false)]
|
||
[TestCase(InvalidNonceKind.AllZero, false)]
|
||
[TestCase(InvalidNonceKind.TooLong, false)]
|
||
[TestCase(InvalidNonceKind.Null, true)]
|
||
[TestCase(InvalidNonceKind.Empty, true)]
|
||
[TestCase(InvalidNonceKind.Short, true)]
|
||
[TestCase(InvalidNonceKind.AllZero, true)]
|
||
[TestCase(InvalidNonceKind.TooLong, true)]
|
||
public async Task SecuredActivateSessionRejectsInvalidServerNonceForAllIdentitiesAsync(
|
||
InvalidNonceKind nonceKind,
|
||
bool useUserName)
|
||
{
|
||
using IDisposable expectation = MockController
|
||
.ExpectNextResponse<ActivateSessionResponse>(
|
||
response => response.ServerNonce = CreateInvalidNonce(nonceKind));
|
||
ConfiguredEndpoint endpoint = await CreateConfiguredEndpointAsync(
|
||
MessageSecurityMode.SignAndEncrypt).ConfigureAwait(false);
|
||
IUserIdentity identity = useUserName
|
||
? new UserIdentity("user1", "password"u8)
|
||
: new UserIdentity();
|
||
|
||
ServiceResultException exception = Assert.ThrowsAsync<ServiceResultException>(
|
||
async () => await ClientFixture
|
||
.ConnectAsync(endpoint, identity)
|
||
.ConfigureAwait(false))!;
|
||
|
||
Assert.That(exception.StatusCode, Is.EqualTo(StatusCodes.BadNonceInvalid));
|
||
}
|
||
|
||
[Test]
|
||
public async Task SecuredActivateSessionRejectsReusedServerNonceAsync()
|
||
{
|
||
ByteString createSessionNonce = default;
|
||
using IDisposable capture = MockController
|
||
.WhenRequest<CreateSessionRequest, CreateSessionResponse>(
|
||
(_, response) => createSessionNonce = response.ServerNonce);
|
||
using IDisposable reuse = MockController
|
||
.ExpectNextResponse<ActivateSessionResponse>(
|
||
response => response.ServerNonce = createSessionNonce);
|
||
ConfiguredEndpoint endpoint = await CreateConfiguredEndpointAsync(
|
||
MessageSecurityMode.SignAndEncrypt).ConfigureAwait(false);
|
||
|
||
ServiceResultException exception = Assert.ThrowsAsync<ServiceResultException>(
|
||
async () => await ClientFixture.ConnectAsync(endpoint).ConfigureAwait(false))!;
|
||
|
||
Assert.That(exception.StatusCode, Is.EqualTo(StatusCodes.BadNonceInvalid));
|
||
}
|
||
|
||
[Test]
|
||
public async Task SecuredActivateSessionRejectsNonConsecutiveReusedServerNonceAsync()
|
||
{
|
||
ByteString firstActivationNonce = default;
|
||
int activationCount = 0;
|
||
using IDisposable mutation = MockController
|
||
.WhenRequest<ActivateSessionRequest, ActivateSessionResponse>(
|
||
(_, response) =>
|
||
{
|
||
activationCount++;
|
||
if (activationCount == 1)
|
||
{
|
||
firstActivationNonce = response.ServerNonce;
|
||
}
|
||
else if (activationCount == 3)
|
||
{
|
||
response.ServerNonce = firstActivationNonce;
|
||
}
|
||
});
|
||
ConfiguredEndpoint endpoint = await CreateConfiguredEndpointAsync(
|
||
MessageSecurityMode.SignAndEncrypt).ConfigureAwait(false);
|
||
ISession session = await ClientFixture.ConnectAsync(endpoint).ConfigureAwait(false);
|
||
try
|
||
{
|
||
await session.UpdateSessionAsync(
|
||
session.Identity,
|
||
default,
|
||
CancellationToken.None).ConfigureAwait(false);
|
||
|
||
ServiceResultException exception = Assert.ThrowsAsync<ServiceResultException>(
|
||
async () => await session.UpdateSessionAsync(
|
||
session.Identity,
|
||
default,
|
||
CancellationToken.None).ConfigureAwait(false))!;
|
||
|
||
Assert.That(exception.StatusCode, Is.EqualTo(StatusCodes.BadNonceInvalid));
|
||
}
|
||
finally
|
||
{
|
||
await session.CloseAsync(5000, true).ConfigureAwait(false);
|
||
session.Dispose();
|
||
}
|
||
}
|
||
|
||
[Test]
|
||
public async Task ApplySessionConfigurationSeedsServerNonceHistoryAsync()
|
||
{
|
||
ConfiguredEndpoint endpoint = await CreateConfiguredEndpointAsync(
|
||
MessageSecurityMode.SignAndEncrypt).ConfigureAwait(false);
|
||
ISession session = await ClientFixture.ConnectAsync(endpoint).ConfigureAwait(false);
|
||
try
|
||
{
|
||
SessionConfiguration configuration = session.SaveSessionConfiguration();
|
||
|
||
// A secured session exposes a server nonce; re-applying the saved
|
||
// configuration must seed the lifetime nonce-reuse history with it.
|
||
Assert.That(configuration.ServerNonce.IsNull, Is.False);
|
||
|
||
using IDisposable expectation = MockController
|
||
.ExpectNextResponse<ActivateSessionResponse>(
|
||
response => response.ServerNonce = configuration.ServerNonce);
|
||
|
||
bool applied = session.ApplySessionConfiguration(configuration);
|
||
|
||
Assert.That(applied, Is.True);
|
||
Assert.That(session.Connected, Is.True);
|
||
|
||
ServiceResultException exception = Assert.ThrowsAsync<ServiceResultException>(
|
||
async () => await session.UpdateSessionAsync(
|
||
session.Identity,
|
||
default,
|
||
CancellationToken.None).ConfigureAwait(false))!;
|
||
|
||
Assert.That(exception.StatusCode, Is.EqualTo(StatusCodes.BadNonceInvalid));
|
||
}
|
||
finally
|
||
{
|
||
await session.CloseAsync(5000, true).ConfigureAwait(false);
|
||
session.Dispose();
|
||
}
|
||
}
|
||
|
||
[Test]
|
||
public async Task SuppressNonceValidationErrorsDoesNotAllowInvalidNonceAsync()
|
||
{
|
||
bool original = ClientFixture.Config.SecurityConfiguration
|
||
.SuppressNonceValidationErrors;
|
||
ClientFixture.Config.SecurityConfiguration.SuppressNonceValidationErrors = true;
|
||
try
|
||
{
|
||
using IDisposable expectation = MockController
|
||
.ExpectNextResponse<ActivateSessionResponse>(
|
||
response => response.ServerNonce = CreateInvalidNonce(
|
||
InvalidNonceKind.AllZero));
|
||
ConfiguredEndpoint endpoint = await CreateConfiguredEndpointAsync(
|
||
MessageSecurityMode.SignAndEncrypt).ConfigureAwait(false);
|
||
|
||
ServiceResultException exception = Assert.ThrowsAsync<ServiceResultException>(
|
||
async () => await ClientFixture.ConnectAsync(endpoint).ConfigureAwait(false))!;
|
||
|
||
Assert.That(exception.StatusCode, Is.EqualTo(StatusCodes.BadNonceInvalid));
|
||
}
|
||
finally
|
||
{
|
||
ClientFixture.Config.SecurityConfiguration.SuppressNonceValidationErrors =
|
||
original;
|
||
}
|
||
}
|
||
|
||
[Test]
|
||
public async Task AnonymousSignReconnectRecreatesSessionAsync()
|
||
{
|
||
ConfiguredEndpoint endpoint = await CreateConfiguredEndpointAsync(
|
||
MessageSecurityMode.Sign).ConfigureAwait(false);
|
||
ISession session = await ClientFixture.ConnectAsync(endpoint).ConfigureAwait(false);
|
||
try
|
||
{
|
||
NodeId originalSessionId = session.SessionId;
|
||
|
||
await session.ReconnectAsync(
|
||
connection: null,
|
||
channel: null,
|
||
CancellationToken.None).ConfigureAwait(false);
|
||
|
||
Assert.That(session.SessionId, Is.Not.EqualTo(originalSessionId));
|
||
Assert.That(session.Connected, Is.True);
|
||
}
|
||
finally
|
||
{
|
||
await session.CloseAsync(5000, true).ConfigureAwait(false);
|
||
session.Dispose();
|
||
}
|
||
}
|
||
|
||
[Test]
|
||
public async Task AnonymousSignAndEncryptReconnectReactivatesSessionAsync()
|
||
{
|
||
ConfiguredEndpoint endpoint = await CreateConfiguredEndpointAsync(
|
||
MessageSecurityMode.SignAndEncrypt).ConfigureAwait(false);
|
||
ISession session = await ClientFixture.ConnectAsync(endpoint).ConfigureAwait(false);
|
||
try
|
||
{
|
||
NodeId originalSessionId = session.SessionId;
|
||
|
||
await session.ReconnectAsync(
|
||
connection: null,
|
||
channel: null,
|
||
CancellationToken.None).ConfigureAwait(false);
|
||
|
||
Assert.That(session.SessionId, Is.EqualTo(originalSessionId));
|
||
Assert.That(session.Connected, Is.True);
|
||
}
|
||
finally
|
||
{
|
||
await session.CloseAsync(5000, true).ConfigureAwait(false);
|
||
session.Dispose();
|
||
}
|
||
}
|
||
|
||
[Description("CreateSession – the server returns a MaxRequestMessageSize of 500 bytes.")]
|
||
[Test]
|
||
public Task CreateSessionWithInjectedSmallMaxRequestMessageSizeAsync()
|
||
{
|
||
return AssertCreateSessionAcceptsMessageSizeMutationAsync(
|
||
mutate: (req, resp) => resp.MaxRequestMessageSize = 500);
|
||
}
|
||
|
||
[Description("CreateSession – the server returns a MaxResponseMessageSize equal to the client's request multiplied by 10.")]
|
||
[Test]
|
||
public Task CreateSessionWithInjectedExcessiveMaxResponseMessageSizeAsync()
|
||
{
|
||
return AssertCreateSessionAcceptsMessageSizeMutationAsync(
|
||
mutate: (req, resp) => resp.MaxRequestMessageSize = req.MaxResponseMessageSize * 10);
|
||
}
|
||
|
||
private async Task AssertCreateSessionAcceptsMessageSizeMutationAsync(
|
||
Action<CreateSessionRequest, CreateSessionResponse> mutate)
|
||
{
|
||
using IDisposable expectation = MockController.WhenRequest(mutate);
|
||
|
||
ISession session = await OpenAuxSessionAsync().ConfigureAwait(false);
|
||
try
|
||
{
|
||
Assert.That(session.Connected, Is.True);
|
||
}
|
||
finally
|
||
{
|
||
try
|
||
{
|
||
await session.CloseAsync(5000, true, CancellationToken.None).ConfigureAwait(false);
|
||
}
|
||
catch
|
||
{
|
||
// best effort
|
||
}
|
||
session.Dispose();
|
||
}
|
||
}
|
||
|
||
[Description("ActivateSession – injects service result Bad_IdentityTokenInvalid.")]
|
||
[Test]
|
||
public Task ActivateSessionWithInjectedBadIdentityTokenInvalidAsync()
|
||
{
|
||
return AssertActivateSessionInjectsServiceResultAsync(StatusCodes.BadIdentityTokenInvalid);
|
||
}
|
||
|
||
[Description("ActivateSession – injects service result Bad_IdentityTokenRejected.")]
|
||
[Test]
|
||
public Task ActivateSessionWithInjectedBadIdentityTokenRejectedAsync()
|
||
{
|
||
return AssertActivateSessionInjectsServiceResultAsync(StatusCodes.BadIdentityTokenRejected);
|
||
}
|
||
|
||
[Description("ActivateSession – injects service result Bad_UserAccessDenied.")]
|
||
[Test]
|
||
public Task ActivateSessionWithInjectedBadUserAccessDeniedAsync()
|
||
{
|
||
return AssertActivateSessionInjectsServiceResultAsync(StatusCodes.BadUserAccessDenied);
|
||
}
|
||
|
||
[Description("ActivateSession – injects service result Bad_ApplicationSignatureInvalid.")]
|
||
[Test]
|
||
public Task ActivateSessionWithInjectedBadApplicationSignatureInvalidAsync()
|
||
{
|
||
return AssertActivateSessionInjectsServiceResultAsync(StatusCodes.BadApplicationSignatureInvalid);
|
||
}
|
||
|
||
[Description("ActivateSession – injects service result Bad_UserSignatureInvalid.")]
|
||
[Test]
|
||
public Task ActivateSessionWithInjectedBadUserSignatureInvalidAsync()
|
||
{
|
||
return AssertActivateSessionInjectsServiceResultAsync(StatusCodes.BadUserSignatureInvalid);
|
||
}
|
||
|
||
[Description("ActivateSession – injects service result Bad_NoValidCertificates.")]
|
||
[Test]
|
||
public Task ActivateSessionWithInjectedBadNoValidCertificatesAsync()
|
||
{
|
||
return AssertActivateSessionInjectsServiceResultAsync(StatusCodes.BadNoValidCertificates);
|
||
}
|
||
|
||
[Description("ActivateSession – injects service result Bad_IdentityChangeNotSupported.")]
|
||
[Test]
|
||
public Task ActivateSessionWithInjectedBadIdentityChangeNotSupportedAsync()
|
||
{
|
||
return AssertActivateSessionInjectsServiceResultAsync(StatusCodes.BadIdentityChangeNotSupported);
|
||
}
|
||
|
||
[Description("ActivateSession – injects service result Bad_CertificateTimeInvalid.")]
|
||
[Test]
|
||
public Task ActivateSessionWithInjectedBadCertificateTimeInvalidAsync()
|
||
{
|
||
return AssertActivateSessionInjectsServiceResultAsync(StatusCodes.BadCertificateTimeInvalid);
|
||
}
|
||
|
||
[Description("ActivateSession – injects service result Bad_CertificateIssuerTimeInvalid.")]
|
||
[Test]
|
||
public Task ActivateSessionWithInjectedBadCertificateIssuerTimeInvalidAsync()
|
||
{
|
||
return AssertActivateSessionInjectsServiceResultAsync(StatusCodes.BadCertificateIssuerTimeInvalid);
|
||
}
|
||
|
||
[Description("ActivateSession – injects service result Bad_CertificateHostNameInvalid.")]
|
||
[Test]
|
||
public Task ActivateSessionWithInjectedBadCertificateHostNameInvalidAsync()
|
||
{
|
||
return AssertActivateSessionInjectsServiceResultAsync(StatusCodes.BadCertificateHostNameInvalid);
|
||
}
|
||
|
||
[Description("ActivateSession – injects service result Bad_CertificateUriInvalid.")]
|
||
[Test]
|
||
public Task ActivateSessionWithInjectedBadCertificateUriInvalidAsync()
|
||
{
|
||
return AssertActivateSessionInjectsServiceResultAsync(StatusCodes.BadCertificateUriInvalid);
|
||
}
|
||
|
||
[Description("ActivateSession – injects service result Bad_CertificateUseNotAllowed.")]
|
||
[Test]
|
||
public Task ActivateSessionWithInjectedBadCertificateUseNotAllowedAsync()
|
||
{
|
||
return AssertActivateSessionInjectsServiceResultAsync(StatusCodes.BadCertificateUseNotAllowed);
|
||
}
|
||
|
||
[Description("ActivateSession – injects service result Bad_CertificateIssuerUseNotAllowed.")]
|
||
[Test]
|
||
public Task ActivateSessionWithInjectedBadCertificateIssuerUseNotAllowedAsync()
|
||
{
|
||
return AssertActivateSessionInjectsServiceResultAsync(StatusCodes.BadCertificateIssuerUseNotAllowed);
|
||
}
|
||
|
||
[Description("ActivateSession – injects service result Bad_CertificateUntrusted.")]
|
||
[Test]
|
||
public Task ActivateSessionWithInjectedBadCertificateUntrustedAsync()
|
||
{
|
||
return AssertActivateSessionInjectsServiceResultAsync(StatusCodes.BadCertificateUntrusted);
|
||
}
|
||
|
||
[Description("ActivateSession – injects service result Bad_CertificateRevocationUnknown.")]
|
||
[Test]
|
||
public Task ActivateSessionWithInjectedBadCertificateRevocationUnknownAsync()
|
||
{
|
||
return AssertActivateSessionInjectsServiceResultAsync(StatusCodes.BadCertificateRevocationUnknown);
|
||
}
|
||
|
||
[Description("ActivateSession – injects service result Bad_CertificateIssuerRevocationUnknown.")]
|
||
[Test]
|
||
public Task ActivateSessionWithInjectedBadCertificateIssuerRevocationUnknownAsync()
|
||
{
|
||
return AssertActivateSessionInjectsServiceResultAsync(StatusCodes.BadCertificateIssuerRevocationUnknown);
|
||
}
|
||
|
||
[Description("ActivateSession – injects service result Bad_CertificateRevoked.")]
|
||
[Test]
|
||
public Task ActivateSessionWithInjectedBadCertificateRevokedAsync()
|
||
{
|
||
return AssertActivateSessionInjectsServiceResultAsync(StatusCodes.BadCertificateRevoked);
|
||
}
|
||
|
||
[Description("ActivateSession – injects service result Bad_CertificateIssuerRevoked.")]
|
||
[Test]
|
||
public Task ActivateSessionWithInjectedBadCertificateIssuerRevokedAsync()
|
||
{
|
||
return AssertActivateSessionInjectsServiceResultAsync(StatusCodes.BadCertificateIssuerRevoked);
|
||
}
|
||
|
||
[Description("ActivateSession – injects service result Bad_SecurityChecksFailed.")]
|
||
[Test]
|
||
public Task ActivateSessionWithInjectedBadSecurityChecksFailedAsync()
|
||
{
|
||
return AssertActivateSessionInjectsServiceResultAsync(StatusCodes.BadSecurityChecksFailed);
|
||
}
|
||
|
||
private Task AssertActivateSessionInjectsServiceResultAsync(StatusCode injected)
|
||
{
|
||
using IDisposable expectation = MockController.ExpectNextResponse<ActivateSessionResponse>(
|
||
r => r.ResponseHeader.ServiceResult = injected);
|
||
|
||
ServiceResultException ex = Assert.ThrowsAsync<ServiceResultException>(
|
||
async () => await OpenAuxSessionAsync().ConfigureAwait(false));
|
||
Assert.That(ex.StatusCode, Is.EqualTo(injected));
|
||
return Task.CompletedTask;
|
||
}
|
||
|
||
[Description("CloseSession – the server returns Bad_SessionIdInvalid as the service result.")]
|
||
[Test]
|
||
public async Task CloseSessionWithInjectedBadSessionIdInvalidAsync()
|
||
{
|
||
ISession aux = await OpenAuxSessionAsync().ConfigureAwait(false);
|
||
try
|
||
{
|
||
using IDisposable expectation = MockController.ExpectNextResponse<CloseSessionResponse>(
|
||
r => r.ResponseHeader.ServiceResult = StatusCodes.BadSessionIdInvalid);
|
||
|
||
// Session.CloseAsync swallows ServiceResultException and
|
||
// returns the status code so callers can log it without
|
||
// a try/catch — assert against the returned code.
|
||
StatusCode result = await aux.CloseAsync(5000, true, CancellationToken.None).ConfigureAwait(false);
|
||
Assert.That(result, Is.EqualTo(StatusCodes.BadSessionIdInvalid));
|
||
Assert.That(MockController.HasPendingOneShotExpectations, Is.False);
|
||
}
|
||
finally
|
||
{
|
||
aux.Dispose();
|
||
}
|
||
}
|
||
|
||
[Description("ActivateSession – the server returns an empty ServerNonce in the response.")]
|
||
[Test]
|
||
public async Task ActivateSessionWithInjectedEmptyServerNonceAsync()
|
||
{
|
||
using IDisposable expectation = MockController.WhenRequest<ActivateSessionRequest, ActivateSessionResponse>(
|
||
(req, resp) => resp.ServerNonce = ByteString.Empty);
|
||
|
||
// On a SecurityPolicy=None channel the empty nonce passes
|
||
// through. On a signed channel the client would reject.
|
||
ISession? session = null;
|
||
try
|
||
{
|
||
session = await OpenAuxSessionAsync().ConfigureAwait(false);
|
||
Assert.That(session.Connected, Is.True);
|
||
}
|
||
catch (ServiceResultException)
|
||
{
|
||
// Acceptable on signed channels.
|
||
}
|
||
finally
|
||
{
|
||
if (session != null)
|
||
{
|
||
try
|
||
{
|
||
await session.CloseAsync(5000, true, CancellationToken.None).ConfigureAwait(false);
|
||
}
|
||
catch
|
||
{
|
||
// best effort
|
||
}
|
||
session.Dispose();
|
||
}
|
||
}
|
||
}
|
||
|
||
[Description("ActivateSession – the first entry of the per-result Results array contains Bad_CertificateUriInvalid.")]
|
||
[Test]
|
||
public async Task ActivateSessionWithInjectedFirstResultBadCertificateUriInvalidAsync()
|
||
{
|
||
using IDisposable expectation = MockController.WhenRequest<ActivateSessionRequest, ActivateSessionResponse>(
|
||
(req, resp) =>
|
||
{
|
||
int n = resp.Results == null! ? 0 : resp.Results.Count;
|
||
var mutated = new StatusCode[Math.Max(n, 1)];
|
||
for (int i = 0; i < mutated.Length; i++)
|
||
{
|
||
mutated[i] = i < n && i > 0 ? resp.Results[i] : StatusCodes.Good;
|
||
}
|
||
mutated[0] = StatusCodes.BadCertificateUriInvalid;
|
||
resp.Results = mutated.ToArrayOf();
|
||
});
|
||
|
||
ISession? session = null;
|
||
try
|
||
{
|
||
session = await OpenAuxSessionAsync().ConfigureAwait(false);
|
||
Assert.That(session.Connected, Is.True);
|
||
}
|
||
catch (ServiceResultException)
|
||
{
|
||
// Acceptable: a stricter client would reject.
|
||
}
|
||
finally
|
||
{
|
||
if (session != null)
|
||
{
|
||
try
|
||
{
|
||
await session.CloseAsync(5000, true, CancellationToken.None).ConfigureAwait(false);
|
||
}
|
||
catch
|
||
{
|
||
// best effort
|
||
}
|
||
session.Dispose();
|
||
}
|
||
}
|
||
}
|
||
|
||
[Description("ActivateSession – every entry of the Results array contains a Bad_ status code.")]
|
||
[Test]
|
||
public async Task ActivateSessionWithInjectedAllResultsBadAsync()
|
||
{
|
||
using IDisposable expectation = MockController.WhenRequest<ActivateSessionRequest, ActivateSessionResponse>(
|
||
(req, resp) =>
|
||
{
|
||
int n = resp.Results == null! ? 0 : resp.Results.Count;
|
||
var mutated = new StatusCode[Math.Max(n, 1)];
|
||
for (int i = 0; i < mutated.Length; i++)
|
||
{
|
||
mutated[i] = StatusCodes.BadCertificateUriInvalid;
|
||
}
|
||
resp.Results = mutated.ToArrayOf();
|
||
});
|
||
|
||
ISession? session = null;
|
||
try
|
||
{
|
||
session = await OpenAuxSessionAsync().ConfigureAwait(false);
|
||
Assert.That(session.Connected, Is.True);
|
||
}
|
||
catch (ServiceResultException)
|
||
{
|
||
// Acceptable.
|
||
}
|
||
finally
|
||
{
|
||
if (session != null)
|
||
{
|
||
try
|
||
{
|
||
await session.CloseAsync(5000, true, CancellationToken.None).ConfigureAwait(false);
|
||
}
|
||
catch
|
||
{
|
||
// best effort
|
||
}
|
||
session.Dispose();
|
||
}
|
||
}
|
||
}
|
||
|
||
private async Task<ConfiguredEndpoint> CreateConfiguredEndpointAsync(
|
||
MessageSecurityMode securityMode)
|
||
{
|
||
ArrayOf<EndpointDescription> endpoints = await ClientFixture
|
||
.GetEndpointsAsync(ServerUrl)
|
||
.ConfigureAwait(false);
|
||
EndpointDescription? selected = null;
|
||
for (int i = 0; i < endpoints.Count; i++)
|
||
{
|
||
EndpointDescription candidate = endpoints[i];
|
||
if (candidate.SecurityMode == securityMode &&
|
||
candidate.SecurityPolicyUri == SecurityPolicies.Basic256Sha256)
|
||
{
|
||
selected = candidate;
|
||
break;
|
||
}
|
||
}
|
||
|
||
Assert.That(selected, Is.Not.Null);
|
||
return new ConfiguredEndpoint(
|
||
null,
|
||
selected,
|
||
EndpointConfiguration.Create(ClientFixture.Config));
|
||
}
|
||
|
||
private static ByteString CreateInvalidNonce(InvalidNonceKind nonceKind)
|
||
{
|
||
return nonceKind switch
|
||
{
|
||
InvalidNonceKind.Null => default,
|
||
InvalidNonceKind.Empty => ByteString.Empty,
|
||
InvalidNonceKind.Short => ByteString.From(CreateNonceBytes(31)),
|
||
InvalidNonceKind.AllZero => ByteString.From(new byte[32]),
|
||
InvalidNonceKind.TooLong => ByteString.From(CreateNonceBytes(129)),
|
||
_ => throw new ArgumentOutOfRangeException(nameof(nonceKind))
|
||
};
|
||
}
|
||
|
||
private static byte[] CreateNonceBytes(int length)
|
||
{
|
||
byte[] bytes = new byte[length];
|
||
for (int i = 0; i < bytes.Length; i++)
|
||
{
|
||
bytes[i] = (byte)(i + 1);
|
||
}
|
||
return bytes;
|
||
}
|
||
|
||
public enum InvalidNonceKind
|
||
{
|
||
Null,
|
||
Empty,
|
||
Short,
|
||
AllZero,
|
||
TooLong
|
||
}
|
||
}
|
||
}
|