mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-20 00:31:32 +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>
932 lines
44 KiB
C#
932 lines
44 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.Concurrent;
|
|
using System.Collections.Generic;
|
|
using System.Reflection;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Microsoft.Extensions.Time.Testing;
|
|
using Moq;
|
|
using NUnit.Framework;
|
|
using Opc.Ua.Identity;
|
|
using Opc.Ua.Security.Certificates;
|
|
using Opc.Ua.Tests;
|
|
using ServerSession = Opc.Ua.Server.Session;
|
|
|
|
namespace Opc.Ua.Server.Tests
|
|
{
|
|
/// <summary>
|
|
/// Regression tests for session lifecycle defects: expiry and closing at request
|
|
/// admission, activation racing a close, server-initiated terminations, cancellable
|
|
/// teardown, diagnostics privilege ordering and shutdown.
|
|
/// </summary>
|
|
[TestFixture]
|
|
[Category("Session")]
|
|
public sealed class SessionLifecycleRegressionTests
|
|
{
|
|
private const string kChannelId = "lc-channel";
|
|
private static readonly string[] s_timeoutAudit = ["Session/Timeout"];
|
|
private static readonly string[] s_terminatedAudit = ["Session/Terminated"];
|
|
|
|
/// <summary>
|
|
/// LC-1: a request arriving after the session timeout must not revive the
|
|
/// session; it is rejected and the session is terminated as timed out.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task RequestOnExpiredSessionIsRejectedAndClosesTheSessionAsync()
|
|
{
|
|
using var harness = new Harness();
|
|
CreateSessionResult created = await harness.CreateActivatedSessionAsync().ConfigureAwait(false);
|
|
|
|
harness.Clock.Advance(TimeSpan.FromSeconds(2));
|
|
Assert.That(created.Session.HasExpired, Is.True);
|
|
|
|
ServiceResultException ex = Assert.ThrowsAsync<ServiceResultException>(
|
|
async () => await harness.ValidateAsync(created.AuthenticationToken, RequestType.Read)
|
|
.ConfigureAwait(false))!;
|
|
|
|
Assert.That(ex.StatusCode, Is.EqualTo(StatusCodes.BadSessionClosed));
|
|
Assert.That(harness.Manager.GetSession(created.AuthenticationToken), Is.Null);
|
|
Assert.That(harness.Diagnostics.SessionTimeoutCount, Is.EqualTo(1u));
|
|
}
|
|
|
|
/// <summary>
|
|
/// LC-1: a request at the boundary must not refresh the contact time of a
|
|
/// session whose timeout has elapsed.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task RejectedRequestDoesNotReviveAnExpiredSessionAsync()
|
|
{
|
|
using var harness = new Harness();
|
|
CreateSessionResult created = await harness.CreateActivatedSessionAsync().ConfigureAwait(false);
|
|
var session = (ServerSession)created.Session;
|
|
var channel = new SecureChannelContext(kChannelId, harness.Endpoint, RequestEncoding.Binary);
|
|
|
|
harness.Clock.Advance(TimeSpan.FromSeconds(2));
|
|
|
|
Assert.That(
|
|
() => session.ValidateRequest(
|
|
new RequestHeader { AuthenticationToken = created.AuthenticationToken },
|
|
channel,
|
|
RequestType.Read),
|
|
Throws.TypeOf<ServiceResultException>()
|
|
.With.Property(nameof(ServiceResultException.StatusCode))
|
|
.EqualTo((StatusCode)StatusCodes.BadSessionClosed));
|
|
Assert.That(session.HasExpired, Is.True, "A rejected request must not refresh the contact time.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// LC-1 / LC-2: a session that is being closed admits no new requests.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task RequestOnClosingSessionIsRejectedAsync()
|
|
{
|
|
using var harness = new Harness();
|
|
CreateSessionResult created = await harness.CreateActivatedSessionAsync().ConfigureAwait(false);
|
|
Assert.That(((ServerSession)created.Session).MarkClosing(), Is.True);
|
|
|
|
ServiceResultException ex = Assert.ThrowsAsync<ServiceResultException>(
|
|
async () => await harness.ValidateAsync(created.AuthenticationToken, RequestType.Read)
|
|
.ConfigureAwait(false))!;
|
|
|
|
Assert.That(ex.StatusCode, Is.EqualTo(StatusCodes.BadSessionClosed));
|
|
Assert.That(harness.Diagnostics.SessionTimeoutCount, Is.Zero, "A closing session is not a timeout.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// LC-3: ActivateSession on a session that a timeout or termination has started
|
|
/// closing must fail instead of returning Good for a session about to be removed.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task ActivateSessionOnClosingSessionFailsAsync()
|
|
{
|
|
using var harness = new Harness();
|
|
CreateSessionResult created = await harness.CreateActivatedSessionAsync().ConfigureAwait(false);
|
|
Assert.That(((ServerSession)created.Session).MarkClosing(), Is.True);
|
|
|
|
ServiceResultException ex = Assert.ThrowsAsync<ServiceResultException>(
|
|
async () => await harness.ActivateAsync(created.AuthenticationToken).ConfigureAwait(false))!;
|
|
|
|
Assert.That(ex.StatusCode, Is.EqualTo(StatusCodes.BadSessionClosed));
|
|
}
|
|
|
|
/// <summary>
|
|
/// LC-7: a request before ActivateSession makes the server terminate the session
|
|
/// through the regular close path, counted in SessionAbortCount.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task RequestBeforeActivationTerminatesAndCountsAnAbortAsync()
|
|
{
|
|
using var harness = new Harness();
|
|
CreateSessionResult created = await harness.CreateSessionAsync().ConfigureAwait(false);
|
|
|
|
ServiceResultException ex = Assert.ThrowsAsync<ServiceResultException>(
|
|
async () => await harness.ValidateAsync(created.AuthenticationToken, RequestType.Read)
|
|
.ConfigureAwait(false))!;
|
|
|
|
Assert.That(ex.StatusCode, Is.EqualTo(StatusCodes.BadSessionNotActivated));
|
|
harness.Server.Verify(
|
|
s => s.CloseSessionAsync(It.IsAny<OperationContext>(), created.SessionId, false, It.IsAny<CancellationToken>()),
|
|
Times.Once);
|
|
Assert.That(harness.Manager.GetSession(created.AuthenticationToken), Is.Null);
|
|
Assert.That(harness.Diagnostics.SessionAbortCount, Is.EqualTo(1u));
|
|
}
|
|
|
|
/// <summary>
|
|
/// LC-8: once the session is removed its teardown is not cancellable, so the
|
|
/// SessionDiagnostics node is always deleted.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task CloseSessionDeletesDiagnosticsWithAnUncancellableTokenAsync()
|
|
{
|
|
using var harness = new Harness();
|
|
CreateSessionResult created = await harness.CreateActivatedSessionAsync().ConfigureAwait(false);
|
|
using var cts = new CancellationTokenSource();
|
|
|
|
await harness.Manager.CloseSessionAsync(created.SessionId, cts.Token).ConfigureAwait(false);
|
|
|
|
Assert.That(harness.DeleteTokens, Has.Count.EqualTo(1));
|
|
Assert.That(harness.DeleteTokens[0].CanBeCanceled, Is.False);
|
|
Assert.That(harness.Diagnostics.CurrentSessionCount, Is.Zero);
|
|
}
|
|
|
|
/// <summary>
|
|
/// LC-9: the stale identity is re-evaluated before the return-diagnostics
|
|
/// privilege is decided.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task StaleIdentityIsReevaluatedBeforeDiagnosticsPrivilegeAsync()
|
|
{
|
|
using var harness = new Harness(recordOrder: true);
|
|
CreateSessionResult created = await harness.CreateActivatedSessionAsync().ConfigureAwait(false);
|
|
harness.Order.Clear();
|
|
|
|
using OperationContext context = await harness.ValidateAsync(
|
|
created.AuthenticationToken,
|
|
RequestType.Read).ConfigureAwait(false);
|
|
|
|
Assert.That(harness.Order, Has.Count.EqualTo(2));
|
|
Assert.That(harness.Order[0], Is.EqualTo("reevaluate"));
|
|
Assert.That(harness.Order[1], Is.EqualTo("diagnostics"));
|
|
}
|
|
|
|
/// <summary>
|
|
/// LC-10: sessions still open at shutdown are closed like any other session.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task ShutdownClosesRemainingSessionsAsync()
|
|
{
|
|
using var harness = new Harness();
|
|
CreateSessionResult created = await harness.CreateActivatedSessionAsync().ConfigureAwait(false);
|
|
int closing = 0;
|
|
harness.Manager.SessionClosing += (_, _) => Interlocked.Increment(ref closing);
|
|
|
|
await harness.Manager.ShutdownAsync().ConfigureAwait(false);
|
|
|
|
Assert.That(harness.Manager.GetSession(created.AuthenticationToken), Is.Null);
|
|
Assert.That(closing, Is.EqualTo(1));
|
|
Assert.That(harness.DeleteTokens, Has.Count.EqualTo(1), "The SessionDiagnostics node is removed.");
|
|
Assert.That(harness.Diagnostics.CurrentSessionCount, Is.Zero);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A session still open at shutdown is terminated by the server and audited once,
|
|
/// after its close completed and the session monitor stopped (OPC 10000-5 6.4.7).
|
|
/// </summary>
|
|
[Test]
|
|
public async Task ShutdownAuditsEachRemainingSessionOnceAsync()
|
|
{
|
|
using var harness = new Harness(auditing: true);
|
|
CreateSessionResult created = await harness.CreateActivatedSessionAsync().ConfigureAwait(false);
|
|
int closingAtAudit = -1;
|
|
int closing = 0;
|
|
harness.Manager.SessionClosing += (_, _) => Interlocked.Increment(ref closing);
|
|
harness.Server.Setup(s => s.ReportAuditEvent(It.IsAny<ISystemContext>(), It.IsAny<AuditEventState>()))
|
|
.Callback<ISystemContext, AuditEventState>((_, e) =>
|
|
{
|
|
lock (harness.Audits)
|
|
{
|
|
closingAtAudit = Volatile.Read(ref closing);
|
|
harness.Audits.Add(e.SourceName?.Value ?? string.Empty);
|
|
}
|
|
});
|
|
harness.Audits.Clear();
|
|
|
|
await harness.Manager.ShutdownAsync().ConfigureAwait(false);
|
|
|
|
Assert.That(harness.Audits, Is.EqualTo(s_terminatedAudit));
|
|
Assert.That(closingAtAudit, Is.EqualTo(1), "The termination is audited after the close.");
|
|
Assert.That(created.Session.IsClosing, Is.True);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A session that another close (a client close or a timeout) claimed before shutdown
|
|
/// is audited by that close, not again as terminated by the shutdown.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task ShutdownDoesNotAuditASessionAnotherCloseClaimedAsync()
|
|
{
|
|
using var harness = new Harness(auditing: true);
|
|
CreateSessionResult created = await harness.CreateActivatedSessionAsync().ConfigureAwait(false);
|
|
Assert.That(((ServerSession)created.Session).MarkClosing(), Is.True);
|
|
harness.Audits.Clear();
|
|
|
|
await harness.Manager.ShutdownAsync().ConfigureAwait(false);
|
|
|
|
Assert.That(harness.Audits, Is.Empty);
|
|
Assert.That(harness.Manager.GetSession(created.AuthenticationToken), Is.Null);
|
|
}
|
|
|
|
/// <summary>
|
|
/// LC-6: requests rejected by session validation are counted in the server's
|
|
/// RejectedRequestsCount, security rejections also in SecurityRejectedRequestsCount.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task ValidationRejectionsAreCountedInServerDiagnosticsAsync()
|
|
{
|
|
var clock = new FakeTimeProvider(DateTimeOffset.UtcNow);
|
|
var fixture = new Opc.Ua.Server.TestFramework.ServerFixture<SessionManagerExpiryTests.ExpiringSessionServer>(
|
|
t => new SessionManagerExpiryTests.ExpiringSessionServer(t, clock));
|
|
SessionManagerExpiryTests.ExpiringSessionServer server = await fixture.StartAsync().ConfigureAwait(false);
|
|
try
|
|
{
|
|
(RequestHeader requestHeader, SecureChannelContext secureChannelContext) =
|
|
await Opc.Ua.Server.TestFramework.ServerFixtureUtils
|
|
.CreateAndActivateSessionAsync(server, "RejectedCount")
|
|
.ConfigureAwait(false);
|
|
var serverInternal = (ServerInternalData)server.CurrentInstance;
|
|
uint rejectedBefore = serverInternal.ServerDiagnostics.RejectedRequestsCount;
|
|
uint securityRejectedBefore = serverInternal.ServerDiagnostics.SecurityRejectedRequestsCount;
|
|
ArrayOf<ReadValueId> nodesToRead =
|
|
[
|
|
new ReadValueId
|
|
{
|
|
NodeId = VariableIds.Server_ServerStatus_State,
|
|
AttributeId = Attributes.Value
|
|
}
|
|
];
|
|
|
|
// A token replayed from a different SecureChannel.
|
|
var otherChannel = new SecureChannelContext(
|
|
"other-channel",
|
|
secureChannelContext.EndpointDescription,
|
|
RequestEncoding.Binary);
|
|
ServiceResultException ex = Assert.ThrowsAsync<ServiceResultException>(
|
|
async () => await server.ReadAsync(
|
|
otherChannel,
|
|
new RequestHeader { AuthenticationToken = requestHeader.AuthenticationToken },
|
|
0,
|
|
TimestampsToReturn.Neither,
|
|
nodesToRead,
|
|
RequestLifetime.None).ConfigureAwait(false))!;
|
|
Assert.That(ex.StatusCode, Is.EqualTo(StatusCodes.BadSecureChannelIdInvalid));
|
|
|
|
Assert.That(
|
|
serverInternal.ServerDiagnostics.RejectedRequestsCount,
|
|
Is.EqualTo(rejectedBefore + 1));
|
|
Assert.That(
|
|
serverInternal.ServerDiagnostics.SecurityRejectedRequestsCount,
|
|
Is.EqualTo(securityRejectedBefore + 1));
|
|
|
|
await Opc.Ua.Server.TestFramework.ServerFixtureUtils
|
|
.CloseSessionAsync(server, secureChannelContext, requestHeader, CancellationToken.None)
|
|
.ConfigureAwait(false);
|
|
}
|
|
finally
|
|
{
|
|
await fixture.StopAsync().ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// K16: through ServerInternalData, concurrent terminations of one session close,
|
|
/// count and audit it once, and terminating a session that another close has
|
|
/// already claimed, or that no longer exists, counts nothing.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task TerminateSessionThroughServerInternalDataCountsOnceAsync()
|
|
{
|
|
var fixture = new Opc.Ua.Server.TestFramework.ServerFixture<StandardServer>(t => new StandardServer(t));
|
|
StandardServer server = await fixture.StartAsync().ConfigureAwait(false);
|
|
try
|
|
{
|
|
var serverInternal = (ServerInternalData)server.CurrentInstance;
|
|
Microsoft.Extensions.Logging.ILogger logger =
|
|
serverInternal.Telemetry.CreateLogger<SessionLifecycleRegressionTests>();
|
|
await Opc.Ua.Server.TestFramework.ServerFixtureUtils
|
|
.CreateAndActivateSessionAsync(server, "Terminate1")
|
|
.ConfigureAwait(false);
|
|
await Opc.Ua.Server.TestFramework.ServerFixtureUtils
|
|
.CreateAndActivateSessionAsync(server, "Terminate2")
|
|
.ConfigureAwait(false);
|
|
ISession[] sessions = [.. serverInternal.SessionManager.GetSessions()];
|
|
Assert.That(sessions, Has.Length.EqualTo(2));
|
|
uint abortsBefore = serverInternal.ServerDiagnostics.SessionAbortCount;
|
|
|
|
await Task.WhenAll(
|
|
serverInternal.TerminateSessionAsync(sessions[0].Id, false, logger).AsTask(),
|
|
serverInternal.TerminateSessionAsync(sessions[0].Id, false, logger).AsTask())
|
|
.ConfigureAwait(false);
|
|
await serverInternal.TerminateSessionAsync(sessions[0].Id, false, logger).ConfigureAwait(false);
|
|
|
|
Assert.That(sessions[0].IsClosing, Is.True);
|
|
Assert.That(serverInternal.SessionManager.GetSessions(), Does.Not.Contain(sessions[0]));
|
|
Assert.That(serverInternal.ServerDiagnostics.SessionAbortCount, Is.EqualTo(abortsBefore + 1));
|
|
|
|
// Another close has claimed the second session: the termination stays out of it.
|
|
Assert.That(((ServerSession)sessions[1]).MarkClosing(), Is.True);
|
|
await serverInternal.TerminateSessionAsync(sessions[1].Id, false, logger).ConfigureAwait(false);
|
|
Assert.That(serverInternal.ServerDiagnostics.SessionAbortCount, Is.EqualTo(abortsBefore + 1));
|
|
await serverInternal.SessionManager.CloseSessionAsync(sessions[1].Id).ConfigureAwait(false);
|
|
Assert.That(serverInternal.SessionManager.GetSessions(), Is.Empty);
|
|
}
|
|
finally
|
|
{
|
|
await fixture.StopAsync().ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// K17: a never-activated session whose timeout has also elapsed is closed as a
|
|
/// timeout, counted and audited once, not as an abort.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task RequestOnExpiredNeverActivatedSessionIsCountedAsATimeoutAsync()
|
|
{
|
|
using var harness = new Harness(auditing: true);
|
|
CreateSessionResult created = await harness.CreateSessionAsync().ConfigureAwait(false);
|
|
harness.Clock.Advance(TimeSpan.FromSeconds(2));
|
|
|
|
ServiceResultException ex = Assert.ThrowsAsync<ServiceResultException>(
|
|
async () => await harness.ValidateAsync(created.AuthenticationToken, RequestType.Read)
|
|
.ConfigureAwait(false))!;
|
|
|
|
Assert.That(ex.StatusCode, Is.EqualTo(StatusCodes.BadSessionNotActivated));
|
|
Assert.That(harness.Manager.GetSession(created.AuthenticationToken), Is.Null);
|
|
Assert.That(harness.Diagnostics.SessionTimeoutCount, Is.EqualTo(1u));
|
|
Assert.That(harness.Diagnostics.SessionAbortCount, Is.Zero);
|
|
Assert.That(harness.Audits, Is.EqualTo(s_timeoutAudit));
|
|
}
|
|
|
|
/// <summary>
|
|
/// K17: a session that another close has already claimed is not also counted
|
|
/// and audited as a timeout when its expiry is observed.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task ExpiryOfAClosingSessionIsNotCountedAsATimeoutAsync()
|
|
{
|
|
using var harness = new Harness(auditing: true);
|
|
CreateSessionResult created = await harness.CreateActivatedSessionAsync().ConfigureAwait(false);
|
|
harness.Audits.Clear();
|
|
Assert.That(((ServerSession)created.Session).MarkClosing(), Is.True);
|
|
harness.Clock.Advance(TimeSpan.FromSeconds(2));
|
|
|
|
ServiceResultException ex = Assert.ThrowsAsync<ServiceResultException>(
|
|
async () => await harness.ActivateAsync(created.AuthenticationToken).ConfigureAwait(false))!;
|
|
|
|
Assert.That(ex.StatusCode, Is.EqualTo(StatusCodes.BadSessionClosed));
|
|
Assert.That(harness.Diagnostics.SessionTimeoutCount, Is.Zero);
|
|
Assert.That(harness.Audits, Is.Empty);
|
|
}
|
|
|
|
/// <summary>
|
|
/// K4: closing the oldest non-activated session at the cap (Part 4 5.7.2.1) is a
|
|
/// termination by the server: counted in SessionAbortCount and audited once.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task CapEvictionIsCountedAndAuditedOnceAsync()
|
|
{
|
|
using var harness = new Harness(maxSessionCount: 1, auditing: true);
|
|
CreateSessionResult first = await harness.CreateSessionAsync().ConfigureAwait(false);
|
|
|
|
CreateSessionResult second = await harness.CreateSessionAsync().ConfigureAwait(false);
|
|
|
|
Assert.That(harness.Manager.GetSession(first.AuthenticationToken), Is.Null);
|
|
Assert.That(harness.Manager.GetSession(second.AuthenticationToken), Is.Not.Null);
|
|
Assert.That(harness.Diagnostics.SessionAbortCount, Is.EqualTo(1u));
|
|
Assert.That(harness.Audits, Is.EqualTo(s_terminatedAudit));
|
|
}
|
|
|
|
/// <summary>
|
|
/// K5 / K6: a session whose ActivateSession is in flight is not evicted at the
|
|
/// cap. CreateSession neither waits for the (possibly slow) authentication nor
|
|
/// turns the activation into a Good response for a session it then closes.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task CapEvictionSkipsASessionWithAnActivationInFlightAsync()
|
|
{
|
|
using var harness = new Harness(maxSessionCount: 1);
|
|
CreateSessionResult first = await harness.CreateSessionAsync().ConfigureAwait(false);
|
|
harness.Authenticator.Gate = new TaskCompletionSource<bool>(
|
|
TaskCreationOptions.RunContinuationsAsynchronously);
|
|
Task activation = harness.ActivateAsync(first.AuthenticationToken);
|
|
Assert.That(
|
|
await Task.WhenAny(harness.Authenticator.Entered.Task, Task.Delay(TimeSpan.FromSeconds(10)))
|
|
.ConfigureAwait(false),
|
|
Is.SameAs(harness.Authenticator.Entered.Task));
|
|
|
|
Task<CreateSessionResult> create = harness.CreateSessionAsync();
|
|
Task completed = await Task.WhenAny(create, Task.Delay(TimeSpan.FromSeconds(10))).ConfigureAwait(false);
|
|
harness.Authenticator.Gate.TrySetResult(true);
|
|
|
|
Assert.That(completed, Is.SameAs(create), "CreateSession waited for the activation.");
|
|
ServiceResultException ex = Assert.ThrowsAsync<ServiceResultException>(async () => await create.ConfigureAwait(false))!;
|
|
Assert.That(ex.StatusCode, Is.EqualTo(StatusCodes.BadTooManySessions));
|
|
await activation.ConfigureAwait(false);
|
|
Assert.That(first.Session.Activated, Is.True);
|
|
Assert.That(harness.Manager.GetSession(first.AuthenticationToken), Is.SameAs(first.Session));
|
|
}
|
|
|
|
/// <summary>
|
|
/// SWEEP-2: CloseSession on a session whose timeout elapsed before the session
|
|
/// monitor swept it is the client's close, not a timeout: it is admitted so the
|
|
/// requested DeleteSubscriptions is honoured. Other requests are still rejected.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task CloseSessionOnAnExpiredSessionIsAdmittedAsync()
|
|
{
|
|
using var harness = new Harness();
|
|
CreateSessionResult created = await harness.CreateActivatedSessionAsync().ConfigureAwait(false);
|
|
harness.Clock.Advance(TimeSpan.FromSeconds(2));
|
|
|
|
using OperationContext context = await harness.ValidateAsync(
|
|
created.AuthenticationToken,
|
|
RequestType.CloseSession).ConfigureAwait(false);
|
|
|
|
Assert.That(context.Session, Is.SameAs(created.Session));
|
|
Assert.That(harness.Diagnostics.SessionTimeoutCount, Is.Zero);
|
|
}
|
|
|
|
/// <summary>
|
|
/// SWEEP-2: CloseSession on a session that is already closing is still rejected.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task CloseSessionOnAClosingSessionIsRejectedAsync()
|
|
{
|
|
using var harness = new Harness();
|
|
CreateSessionResult created = await harness.CreateActivatedSessionAsync().ConfigureAwait(false);
|
|
Assert.That(((ServerSession)created.Session).MarkClosing(), Is.True);
|
|
|
|
ServiceResultException ex = Assert.ThrowsAsync<ServiceResultException>(
|
|
async () => await harness.ValidateAsync(created.AuthenticationToken, RequestType.CloseSession)
|
|
.ConfigureAwait(false))!;
|
|
|
|
Assert.That(ex.StatusCode, Is.EqualTo(StatusCodes.BadSessionClosed));
|
|
}
|
|
|
|
/// <summary>
|
|
/// PR review 4168294597: the session manager records that the client
|
|
/// certificate's validation error was accepted before it publishes the Session,
|
|
/// so a derived manager (e.g. one mirroring the Session after the base
|
|
/// CreateSessionAsync returns) never reads it as validated.
|
|
/// </summary>
|
|
[TestCase(false)]
|
|
[TestCase(true)]
|
|
public async Task AcceptedCertificateErrorIsRecordedBeforeTheSessionIsPublishedAsync(bool errorAccepted)
|
|
{
|
|
using var harness = new Harness();
|
|
CreateSessionResult created = await harness.CreateSessionAsync(certificateErrorAccepted: errorAccepted)
|
|
.ConfigureAwait(false);
|
|
|
|
Assert.That(ClientCertificateProvenance.IsValidated(created.Session), Is.EqualTo(!errorAccepted));
|
|
}
|
|
|
|
/// <summary>
|
|
/// PR review 4168294669: a CreateSession that is rejected with Bad_NonceInvalid
|
|
/// for reusing another Session's clientNonce does not close the oldest
|
|
/// non-activated Session at the cap to make room for itself.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task DuplicateClientNonceAtCapDoesNotEvictAsync()
|
|
{
|
|
using var harness = new Harness(maxSessionCount: 2, securityMode: MessageSecurityMode.SignAndEncrypt);
|
|
ByteString firstNonce = Nonce.CreateRandomNonceData(32).ToByteString();
|
|
ByteString secondNonce = Nonce.CreateRandomNonceData(32).ToByteString();
|
|
CreateSessionResult first = await harness.CreateSessionAsync(firstNonce).ConfigureAwait(false);
|
|
CreateSessionResult second = await harness.CreateSessionAsync(secondNonce).ConfigureAwait(false);
|
|
|
|
ServiceResultException ex = Assert.ThrowsAsync<ServiceResultException>(
|
|
async () => await harness.CreateSessionAsync(secondNonce).ConfigureAwait(false))!;
|
|
|
|
Assert.That(ex.StatusCode, Is.EqualTo(StatusCodes.BadNonceInvalid));
|
|
Assert.That(harness.Manager.GetSession(first.AuthenticationToken), Is.SameAs(first.Session));
|
|
Assert.That(harness.Manager.GetSession(second.AuthenticationToken), Is.SameAs(second.Session));
|
|
Assert.That(first.Session.IsClosing, Is.False);
|
|
Assert.That(harness.Diagnostics.SessionAbortCount, Is.Zero);
|
|
}
|
|
|
|
/// <summary>
|
|
/// PR review 4168294838: a custom <see cref="ISession"/> has no closing mark the
|
|
/// server can set, so a close that claimed it is recorded beside it. Request
|
|
/// admission still rejects it with Bad_SessionClosed, like a closing Session.
|
|
/// </summary>
|
|
[Test]
|
|
public async Task RequestOnClaimedCustomSessionIsRejectedAsync()
|
|
{
|
|
using var harness = new Harness();
|
|
var custom = new Mock<ISession>();
|
|
custom.SetupGet(s => s.Id).Returns(new NodeId(4242u, 1));
|
|
custom.SetupGet(s => s.Activated).Returns(true);
|
|
var token = new NodeId(Nonce.CreateRandomNonceData(32).ToByteString());
|
|
harness.AddSession(token, custom.Object);
|
|
|
|
using (OperationContext admitted = await harness.ValidateAsync(token, RequestType.Read).ConfigureAwait(false))
|
|
{
|
|
Assert.That(admitted.Session, Is.SameAs(custom.Object));
|
|
}
|
|
Assert.That(SessionTermination.IsClosingOrClaimed(custom.Object), Is.False);
|
|
|
|
Assert.That(SessionTermination.TryClaimClose(custom.Object), Is.True);
|
|
|
|
Assert.That(custom.Object.IsClosing, Is.False);
|
|
Assert.That(SessionTermination.IsClosingOrClaimed(custom.Object), Is.True);
|
|
ServiceResultException ex = Assert.ThrowsAsync<ServiceResultException>(
|
|
async () => await harness.ValidateAsync(token, RequestType.Read).ConfigureAwait(false))!;
|
|
Assert.That(ex.StatusCode, Is.EqualTo(StatusCodes.BadSessionClosed));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Accepts anonymous tokens like <see cref="AnonymousAuthenticator"/>, optionally
|
|
/// holding the authentication until the test opens the gate.
|
|
/// </summary>
|
|
private sealed class GatedAnonymousAuthenticator : IUserTokenAuthenticator
|
|
{
|
|
public UserTokenType TokenType => UserTokenType.Anonymous;
|
|
|
|
public string? IssuedTokenProfileUri => null;
|
|
|
|
public TaskCompletionSource<bool>? Gate { get; set; }
|
|
|
|
public TaskCompletionSource<bool> Entered { get; } =
|
|
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
|
|
public async ValueTask<AuthenticationResult> AuthenticateAsync(
|
|
AuthenticationContext context,
|
|
CancellationToken ct = default)
|
|
{
|
|
Entered.TrySetResult(true);
|
|
if (Gate is TaskCompletionSource<bool> gate)
|
|
{
|
|
await gate.Task.ConfigureAwait(false);
|
|
}
|
|
return await m_inner.AuthenticateAsync(context, ct).ConfigureAwait(false);
|
|
}
|
|
|
|
private readonly AnonymousAuthenticator m_inner = new();
|
|
}
|
|
|
|
/// <summary>
|
|
/// A session manager over a mocked server whose clock, diagnostics and close path
|
|
/// are observable.
|
|
/// </summary>
|
|
private sealed class Harness : IDisposable
|
|
{
|
|
public Harness(
|
|
bool recordOrder = false,
|
|
int maxSessionCount = 10,
|
|
bool auditing = false,
|
|
MessageSecurityMode securityMode = MessageSecurityMode.None)
|
|
{
|
|
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
|
|
Server.Setup(s => s.Telemetry).Returns(telemetry);
|
|
Server.Setup(s => s.NamespaceUris).Returns(new NamespaceTable());
|
|
Server.Setup(s => s.MessageContext).Returns(ServiceMessageContext.CreateEmpty(telemetry));
|
|
Server.Setup(s => s.IdentityRegistry)
|
|
.Returns(new ServerIdentityRegistry(Authenticator));
|
|
if (auditing)
|
|
{
|
|
Server.Setup(s => s.Auditing).Returns(true);
|
|
Server.Setup(s => s.DefaultAuditContext).Returns(() => Server.Object.DefaultSystemContext);
|
|
Server.Setup(s => s.ReportAuditEvent(It.IsAny<ISystemContext>(), It.IsAny<AuditEventState>()))
|
|
.Callback<ISystemContext, AuditEventState>((_, e) =>
|
|
{
|
|
lock (Audits)
|
|
{
|
|
Audits.Add(e.SourceName?.Value ?? string.Empty);
|
|
}
|
|
});
|
|
}
|
|
Server.Setup(s => s.UpdateServerDiagnostics(It.IsAny<Action<ServerDiagnosticsSummaryDataType>>()))
|
|
.Callback<Action<ServerDiagnosticsSummaryDataType>>(update =>
|
|
{
|
|
lock (Diagnostics)
|
|
{
|
|
update(Diagnostics);
|
|
}
|
|
});
|
|
var diagnostics = new Mock<IDiagnosticsNodeManager>();
|
|
int nextSessionId = 0;
|
|
diagnostics.Setup(d => d.CreateSessionDiagnosticsAsync(
|
|
It.IsAny<ServerSystemContext>(),
|
|
It.IsAny<SessionDiagnosticsDataType>(),
|
|
It.IsAny<NodeValueSimpleEventHandler>(),
|
|
It.IsAny<SessionSecurityDiagnosticsDataType>(),
|
|
It.IsAny<NodeValueSimpleEventHandler>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.Returns(() => new ValueTask<NodeId>(
|
|
new NodeId((uint)Interlocked.Increment(ref nextSessionId), 1)));
|
|
diagnostics.Setup(d => d.DeleteSessionDiagnosticsAsync(
|
|
It.IsAny<ServerSystemContext>(),
|
|
It.IsAny<NodeId>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.Callback<ServerSystemContext, NodeId, CancellationToken>((_, _, ct) => DeleteTokens.Add(ct))
|
|
.Returns(default(ValueTask));
|
|
Server.Setup(s => s.DiagnosticsNodeManager).Returns(diagnostics.Object);
|
|
Server.Setup(s => s.DefaultSystemContext).Returns(new ServerSystemContext(Server.Object));
|
|
|
|
var configuration = new ApplicationConfiguration
|
|
{
|
|
ServerConfiguration = new ServerConfiguration
|
|
{
|
|
MinSessionTimeout = 1_000,
|
|
MaxSessionTimeout = 60_000,
|
|
MaxSessionCount = maxSessionCount,
|
|
MaxBrowseContinuationPoints = 10,
|
|
MaxHistoryContinuationPoints = 10
|
|
}
|
|
};
|
|
m_certificate = DefaultCertificateFactory.Instance
|
|
.CreateApplicationCertificate(
|
|
"urn:opcfoundation:test:session-lifecycle",
|
|
"SessionLifecycle",
|
|
"CN=SessionLifecycle",
|
|
["localhost"])
|
|
.SetRSAKeySize(2048)
|
|
.CreateForRSA();
|
|
Manager = recordOrder
|
|
? new OrderRecordingSessionManager(Server.Object, configuration, Clock, Order)
|
|
: new SessionManager(Server.Object, configuration, Clock);
|
|
Server.Setup(s => s.SessionManager).Returns(Manager);
|
|
|
|
// The timeout and termination paths close through the server; route them to
|
|
// the session manager as ServerInternalData does.
|
|
Server.Setup(s => s.CloseSessionAsync(
|
|
It.IsAny<OperationContext>(),
|
|
It.IsAny<NodeId>(),
|
|
It.IsAny<bool>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.Returns<OperationContext, NodeId, bool, CancellationToken>(
|
|
(_, id, _, ct) => Manager.CloseSessionAsync(id, ct));
|
|
|
|
Endpoint = new EndpointDescription
|
|
{
|
|
EndpointUrl = "opc.tcp://localhost/lifecycle",
|
|
SecurityPolicyUri = securityMode == MessageSecurityMode.None
|
|
? SecurityPolicies.None
|
|
: SecurityPolicies.Basic256Sha256,
|
|
SecurityMode = securityMode,
|
|
UserIdentityTokens =
|
|
[
|
|
new UserTokenPolicy
|
|
{
|
|
PolicyId = "anonymous",
|
|
TokenType = UserTokenType.Anonymous,
|
|
SecurityPolicyUri = SecurityPolicies.None
|
|
}
|
|
]
|
|
};
|
|
}
|
|
|
|
public Mock<IServerInternal> Server { get; } = new();
|
|
|
|
public FakeTimeProvider Clock { get; } = new();
|
|
|
|
public SessionManager Manager { get; }
|
|
|
|
public EndpointDescription Endpoint { get; }
|
|
|
|
public ServerDiagnosticsSummaryDataType Diagnostics { get; } = new();
|
|
|
|
public List<CancellationToken> DeleteTokens { get; } = [];
|
|
|
|
public List<string> Order { get; } = [];
|
|
|
|
public List<string> Audits { get; } = [];
|
|
|
|
public GatedAnonymousAuthenticator Authenticator { get; } = new();
|
|
|
|
public async Task<CreateSessionResult> CreateSessionAsync(
|
|
ByteString clientNonce = default,
|
|
bool certificateErrorAccepted = false)
|
|
{
|
|
OperationContext context = CreateContext(RequestType.CreateSession);
|
|
context.ClientCertificateErrorAccepted = certificateErrorAccepted;
|
|
// The session owns the client certificate once created; a rejected
|
|
// CreateSession leaves it with the caller.
|
|
Certificate clientCertificate = m_certificate.AddRef();
|
|
CreateSessionResult created;
|
|
try
|
|
{
|
|
created = await Manager.CreateSessionAsync(
|
|
context,
|
|
m_certificate,
|
|
"lifecycle-regression",
|
|
clientNonce.IsEmpty ? ByteString.From(new byte[32]) : clientNonce,
|
|
new ApplicationDescription
|
|
{
|
|
ApplicationUri = "urn:opcfoundation:test:session-lifecycle"
|
|
},
|
|
Endpoint.EndpointUrl,
|
|
clientCertificate,
|
|
[],
|
|
1_000,
|
|
64 * 1024,
|
|
CancellationToken.None).ConfigureAwait(false);
|
|
}
|
|
catch
|
|
{
|
|
clientCertificate.Dispose();
|
|
throw;
|
|
}
|
|
// SessionManager leaves CurrentSessionCount to its caller.
|
|
Diagnostics.CurrentSessionCount++;
|
|
return created;
|
|
}
|
|
|
|
public async Task<CreateSessionResult> CreateActivatedSessionAsync()
|
|
{
|
|
CreateSessionResult created = await CreateSessionAsync().ConfigureAwait(false);
|
|
await ActivateAsync(created.AuthenticationToken).ConfigureAwait(false);
|
|
return created;
|
|
}
|
|
|
|
public async Task ActivateAsync(NodeId authenticationToken)
|
|
{
|
|
await Manager.ActivateSessionAsync(
|
|
CreateContext(RequestType.ActivateSession),
|
|
authenticationToken,
|
|
new SignatureData(),
|
|
default,
|
|
new SignatureData(),
|
|
[],
|
|
CancellationToken.None).ConfigureAwait(false);
|
|
}
|
|
|
|
public async Task<OperationContext> ValidateAsync(NodeId authenticationToken, RequestType requestType)
|
|
{
|
|
return await Manager.ValidateRequestAsync(
|
|
new RequestHeader { AuthenticationToken = authenticationToken },
|
|
new SecureChannelContext(kChannelId, Endpoint, RequestEncoding.Binary),
|
|
requestType,
|
|
RequestLifetime.None).ConfigureAwait(false);
|
|
}
|
|
|
|
public void AddSession(NodeId authenticationToken, ISession session)
|
|
{
|
|
var sessions = (ConcurrentDictionary<NodeId, ISession>)typeof(SessionManager)
|
|
.GetField("m_sessions", BindingFlags.Instance | BindingFlags.NonPublic)!
|
|
.GetValue(Manager)!;
|
|
Assert.That(sessions.TryAdd(authenticationToken, session), Is.True);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
Manager.Dispose();
|
|
m_certificate.Dispose();
|
|
}
|
|
|
|
private OperationContext CreateContext(RequestType requestType)
|
|
{
|
|
return new OperationContext(
|
|
new RequestHeader(),
|
|
new SecureChannelContext(kChannelId, Endpoint, RequestEncoding.Binary),
|
|
requestType,
|
|
RequestLifetime.None);
|
|
}
|
|
|
|
private readonly Certificate m_certificate;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Records the order of identity re-evaluation and the diagnostics privilege check.
|
|
/// </summary>
|
|
private sealed class OrderRecordingSessionManager : SessionManager
|
|
{
|
|
public OrderRecordingSessionManager(
|
|
IServerInternal server,
|
|
ApplicationConfiguration configuration,
|
|
TimeProvider clock,
|
|
List<string> order)
|
|
: base(server, configuration, clock)
|
|
{
|
|
m_order = order;
|
|
}
|
|
|
|
protected override void ReevaluateIdentityIfStale(
|
|
ISession session,
|
|
SecureChannelContext secureChannelContext)
|
|
{
|
|
m_order.Add("reevaluate");
|
|
base.ReevaluateIdentityIfStale(session, secureChannelContext);
|
|
}
|
|
|
|
protected override ISession CreateSession(
|
|
OperationContext context,
|
|
IServerInternal server,
|
|
Certificate serverCertificate,
|
|
NodeId sessionCookie,
|
|
ByteString clientNonce,
|
|
Nonce serverNonce,
|
|
string sessionName,
|
|
ApplicationDescription clientDescription,
|
|
string endpointUrl,
|
|
Certificate clientCertificate,
|
|
CertificateCollection clientCertificateChain,
|
|
double sessionTimeout,
|
|
uint maxResponseMessageSize,
|
|
int maxRequestAge,
|
|
int maxContinuationPoints)
|
|
{
|
|
return new OrderRecordingSession(
|
|
context,
|
|
server,
|
|
serverCertificate,
|
|
sessionCookie,
|
|
clientNonce,
|
|
serverNonce,
|
|
sessionName,
|
|
clientDescription,
|
|
endpointUrl,
|
|
clientCertificate,
|
|
clientCertificateChain,
|
|
sessionTimeout,
|
|
m_order);
|
|
}
|
|
|
|
private readonly List<string> m_order;
|
|
}
|
|
|
|
private sealed class OrderRecordingSession : ServerSession
|
|
{
|
|
public OrderRecordingSession(
|
|
OperationContext context,
|
|
IServerInternal server,
|
|
Certificate serverCertificate,
|
|
NodeId authenticationToken,
|
|
ByteString clientNonce,
|
|
Nonce serverNonce,
|
|
string sessionName,
|
|
ApplicationDescription clientDescription,
|
|
string endpointUrl,
|
|
Certificate clientCertificate,
|
|
CertificateCollection clientCertificateChain,
|
|
double sessionTimeout,
|
|
List<string> order)
|
|
: base(
|
|
context,
|
|
server,
|
|
serverCertificate,
|
|
authenticationToken,
|
|
clientNonce,
|
|
serverNonce,
|
|
sessionName,
|
|
clientDescription,
|
|
endpointUrl,
|
|
clientCertificate,
|
|
clientCertificateChain,
|
|
sessionTimeout,
|
|
10,
|
|
10)
|
|
{
|
|
m_order = order;
|
|
}
|
|
|
|
public override void ValidateDiagnosticInfo(RequestHeader requestHeader)
|
|
{
|
|
m_order.Add("diagnostics");
|
|
base.ValidateDiagnosticInfo(requestHeader);
|
|
}
|
|
|
|
private readonly List<string> m_order;
|
|
}
|
|
}
|
|
}
|