/* ========================================================================
* 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/
* ======================================================================*/
// CA2007: tests run without a SynchronizationContext; ConfigureAwait(false)
// adds noise without a behavioural benefit. Disabled file-level for the suite.
#pragma warning disable CA2007
using System;
using System.Globalization;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Time.Testing;
using Moq;
using NUnit.Framework;
using Opc.Ua.Redundancy;
using Opc.Ua.Redundancy.Server;
using Opc.Ua.Security.Certificates;
using Opc.Ua.Tests;
namespace Opc.Ua.Server.Tests.Redundancy
{
///
/// Unit tests for the security decision logic of
/// : the SecurityPolicy/Mode
/// check and the single-use server-nonce consumption (replay defence) that
/// gate a mirrored fast-reconnect. The full reconstruct + signature path is
/// exercised by the two-server end-to-end test.
///
[TestFixture]
[Category("Distributed")]
[Parallelizable(ParallelScope.All)]
public class DistributedSessionManagerTests
{
private const string PolicyA = "http://opcfoundation.org/UA/SecurityPolicy#Basic256Sha256";
private const string PolicyB = "http://opcfoundation.org/UA/SecurityPolicy#Aes256_Sha256_RsaPss";
private static readonly ByteString s_clientChannelCertificate =
ByteString.From(CreateBytes(32, 0x41));
private static readonly InMemorySharedKeyValueStore s_sessionKv = new();
private static readonly SharedKeyValueSessionStore s_sessionStore =
new(s_sessionKv, ServiceMessageContext.CreateEmpty(NUnitTelemetryContext.Create()));
private static DistributedSessionManager CreateManager(
ISingleUseNonceRegistry nonceRegistry,
ISharedSessionStore? sessionStore = null,
IServerInternal? server = null,
Func? serverCertificateProvider = null,
TimeProvider? timeProvider = null)
{
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
var serverMock = new Mock();
serverMock.Setup(s => s.Telemetry).Returns(telemetry);
serverMock.Setup(s => s.NamespaceUris).Returns(new NamespaceTable());
var configuration = new ApplicationConfiguration
{
ServerConfiguration = new ServerConfiguration
{
MinSessionTimeout = 1000,
MaxSessionTimeout = 3_600_000,
MaxSessionCount = 100,
MaxRequestAge = 60_000,
MaxBrowseContinuationPoints = 10,
MaxHistoryContinuationPoints = 10
}
};
return new DistributedSessionManager(
server ?? serverMock.Object,
configuration,
sessionStore ?? s_sessionStore,
nonceRegistry,
serverCertificateProvider ?? (_ => null),
new DistributedSessionOptions { EnableFastReconnect = true },
timeProvider);
}
private static SharedSessionEntry EntryWithNonce(byte[] nonce)
{
return new SharedSessionEntry
{
SessionId = new NodeId(1, 1),
AuthenticationToken = new NodeId(2, 1),
SecurityPolicyUri = PolicyA,
SecurityMode = (int)MessageSecurityMode.SignAndEncrypt,
ServerNonce = ByteString.From(nonce),
SecurityStateVersion = SharedSessionEntry.CurrentSecurityStateVersion,
OriginalClientChannelCertificate = s_clientChannelCertificate,
ClientUserId = null,
ClientUserTokenType = UserTokenType.Anonymous,
HasActivatedUserIdentity = true
};
}
[Test]
public async Task AuthorizeSucceedsForMatchingPolicyAndFreshNonceAsync()
{
using var registryKv = new InMemorySharedKeyValueStore();
var registry = new SharedSingleUseNonceRegistry(registryKv);
using DistributedSessionManager manager = CreateManager(registry);
SharedSessionEntry entry = EntryWithNonce(CreateBytes(32, 1));
DistributedSessionManager.RestoreDecision decision = await manager.AuthorizeAndConsumeAsync(
entry,
PolicyA,
MessageSecurityMode.SignAndEncrypt,
s_clientChannelCertificate).ConfigureAwait(false);
Assert.That(decision, Is.EqualTo(DistributedSessionManager.RestoreDecision.Authorized));
}
[Test]
public async Task AuthorizeRejectsMismatchedPolicyAsync()
{
using var registryKv = new InMemorySharedKeyValueStore();
var registry = new SharedSingleUseNonceRegistry(registryKv);
using DistributedSessionManager manager = CreateManager(registry);
SharedSessionEntry entry = EntryWithNonce(CreateBytes(32, 2));
DistributedSessionManager.RestoreDecision decision = await manager.AuthorizeAndConsumeAsync(
entry,
PolicyB,
MessageSecurityMode.SignAndEncrypt,
s_clientChannelCertificate).ConfigureAwait(false);
Assert.That(decision, Is.EqualTo(DistributedSessionManager.RestoreDecision.PolicyMismatch));
}
[Test]
public async Task AuthorizeRejectsMismatchedSecurityModeAsync()
{
using var registryKv = new InMemorySharedKeyValueStore();
var registry = new SharedSingleUseNonceRegistry(registryKv);
using DistributedSessionManager manager = CreateManager(registry);
SharedSessionEntry entry = EntryWithNonce(CreateBytes(32, 3));
DistributedSessionManager.RestoreDecision decision = await manager.AuthorizeAndConsumeAsync(
entry,
PolicyA,
MessageSecurityMode.Sign,
s_clientChannelCertificate).ConfigureAwait(false);
Assert.That(decision, Is.EqualTo(DistributedSessionManager.RestoreDecision.PolicyMismatch));
}
[Test]
public async Task AuthorizeRejectsReplayedNonceAsync()
{
using var registryKv = new InMemorySharedKeyValueStore();
var registry = new SharedSingleUseNonceRegistry(registryKv);
using DistributedSessionManager manager = CreateManager(registry);
SharedSessionEntry entry = EntryWithNonce(CreateBytes(32, 9));
DistributedSessionManager.RestoreDecision first = await manager.AuthorizeAndConsumeAsync(
entry,
PolicyA,
MessageSecurityMode.SignAndEncrypt,
s_clientChannelCertificate).ConfigureAwait(false);
DistributedSessionManager.RestoreDecision second = await manager.AuthorizeAndConsumeAsync(
entry,
PolicyA,
MessageSecurityMode.SignAndEncrypt,
s_clientChannelCertificate).ConfigureAwait(false);
Assert.That(first, Is.EqualTo(DistributedSessionManager.RestoreDecision.Authorized));
Assert.That(second, Is.EqualTo(DistributedSessionManager.RestoreDecision.NonceReplayed),
"a captured activation cannot be replayed once the nonce is consumed");
}
[Test]
public async Task AuthorizeRejectsEmptyNonceAsync()
{
using var registryKv = new InMemorySharedKeyValueStore();
var registry = new SharedSingleUseNonceRegistry(registryKv);
using DistributedSessionManager manager = CreateManager(registry);
SharedSessionEntry entry = EntryWithNonce([]);
DistributedSessionManager.RestoreDecision decision = await manager.AuthorizeAndConsumeAsync(
entry,
PolicyA,
MessageSecurityMode.SignAndEncrypt,
s_clientChannelCertificate).ConfigureAwait(false);
Assert.That(decision, Is.EqualTo(DistributedSessionManager.RestoreDecision.NonceInvalid));
}
[Test]
public async Task AuthorizeRejectsMissingVersionedSecurityStateAsync()
{
using var registryKv = new InMemorySharedKeyValueStore();
var registry = new SharedSingleUseNonceRegistry(registryKv);
using DistributedSessionManager manager = CreateManager(registry);
SharedSessionEntry entry = EntryWithNonce(CreateBytes(32, 10)) with
{
SecurityStateVersion = 0
};
DistributedSessionManager.RestoreDecision decision =
await manager.AuthorizeAndConsumeAsync(
entry,
PolicyA,
MessageSecurityMode.SignAndEncrypt,
s_clientChannelCertificate).ConfigureAwait(false);
Assert.That(
decision,
Is.EqualTo(DistributedSessionManager.RestoreDecision.SecurityStateMissing));
}
[Test]
public async Task MirrorKeepsTheNewestNonceWhenActivationsCompleteOutOfOrderAsync()
{
using var sessionKv = new InMemorySharedKeyValueStore();
var sessionStore = new SharedKeyValueSessionStore(
sessionKv,
ServiceMessageContext.CreateEmpty(NUnitTelemetryContext.Create()));
using var registryKv = new InMemorySharedKeyValueStore();
var registry = new SharedSingleUseNonceRegistry(registryKv);
using DistributedSessionManager manager = CreateManager(registry, sessionStore);
SharedSessionEntry entry = EntryWithNonce(CreateBytes(32, 21));
await sessionStore.PutAsync(entry).ConfigureAwait(false);
ByteString newerNonce = ByteString.From(CreateBytes(32, 22));
ByteString olderNonce = ByteString.From(CreateBytes(32, 23));
// The activation gate is released before the mirror callback runs, so
// a slower earlier activation can arrive last. Its nonce is stale and
// must not replace the nonce of the newer activation.
await manager.MirrorActivationIfCurrentAsync(
entry.AuthenticationToken,
newerNonce,
UserTokenType.Anonymous,
null,
activationSequence: 2,
userTokenRequiresEphemeralKey: false,
CancellationToken.None).ConfigureAwait(false);
await manager.MirrorActivationIfCurrentAsync(
entry.AuthenticationToken,
olderNonce,
UserTokenType.Anonymous,
null,
activationSequence: 1,
userTokenRequiresEphemeralKey: false,
CancellationToken.None).ConfigureAwait(false);
SharedSessionEntry? stored = await sessionStore
.TryGetAsync(entry.AuthenticationToken)
.ConfigureAwait(false);
Assert.That(stored, Is.Not.Null);
Assert.That(stored!.ServerNonce, Is.EqualTo(newerNonce));
}
[Test]
public async Task MirrorPersistsTheNonceOfAnAdvancingActivationAsync()
{
using var sessionKv = new InMemorySharedKeyValueStore();
var sessionStore = new SharedKeyValueSessionStore(
sessionKv,
ServiceMessageContext.CreateEmpty(NUnitTelemetryContext.Create()));
using var registryKv = new InMemorySharedKeyValueStore();
var registry = new SharedSingleUseNonceRegistry(registryKv);
using DistributedSessionManager manager = CreateManager(registry, sessionStore);
SharedSessionEntry entry = EntryWithNonce(CreateBytes(32, 24));
await sessionStore.PutAsync(entry).ConfigureAwait(false);
ByteString firstNonce = ByteString.From(CreateBytes(32, 25));
ByteString secondNonce = ByteString.From(CreateBytes(32, 26));
await manager.MirrorActivationIfCurrentAsync(
entry.AuthenticationToken,
firstNonce,
UserTokenType.Anonymous,
null,
activationSequence: 1,
userTokenRequiresEphemeralKey: false,
CancellationToken.None).ConfigureAwait(false);
await manager.MirrorActivationIfCurrentAsync(
entry.AuthenticationToken,
secondNonce,
UserTokenType.Anonymous,
null,
activationSequence: 2,
userTokenRequiresEphemeralKey: false,
CancellationToken.None).ConfigureAwait(false);
SharedSessionEntry? stored = await sessionStore
.TryGetAsync(entry.AuthenticationToken)
.ConfigureAwait(false);
Assert.That(stored, Is.Not.Null);
Assert.That(stored!.ServerNonce, Is.EqualTo(secondNonce));
Assert.That(stored.HasActivatedUserIdentity, Is.True);
}
[Test]
public async Task AuthorizeRejectsPreviousSecurityStateVersionAsync()
{
using var registryKv = new InMemorySharedKeyValueStore();
var registry = new SharedSingleUseNonceRegistry(registryKv);
using DistributedSessionManager manager = CreateManager(registry);
// Version 2 stored a differently derived ClientUserId, so an entry a
// peer replica wrote before the upgrade must fail closed instead of
// being compared against a key this version computes.
SharedSessionEntry entry = EntryWithNonce(CreateBytes(32, 11)) with
{
SecurityStateVersion = 2
};
DistributedSessionManager.RestoreDecision decision =
await manager.AuthorizeAndConsumeAsync(
entry,
PolicyA,
MessageSecurityMode.SignAndEncrypt,
s_clientChannelCertificate).ConfigureAwait(false);
Assert.That(
decision,
Is.EqualTo(DistributedSessionManager.RestoreDecision.SecurityStateMissing));
}
[Test]
public async Task AuthorizeRejectsMissingClientUserIdAsync()
{
using var registryKv = new InMemorySharedKeyValueStore();
var registry = new SharedSingleUseNonceRegistry(registryKv);
using DistributedSessionManager manager = CreateManager(registry);
SharedSessionEntry entry = EntryWithNonce(CreateBytes(32, 11)) with
{
ClientUserId = null,
ClientUserTokenType = UserTokenType.UserName
};
DistributedSessionManager.RestoreDecision decision =
await manager.AuthorizeAndConsumeAsync(
entry,
PolicyA,
MessageSecurityMode.SignAndEncrypt,
s_clientChannelCertificate).ConfigureAwait(false);
Assert.That(
decision,
Is.EqualTo(DistributedSessionManager.RestoreDecision.SecurityStateMissing));
}
[Test]
public async Task AuthorizeRejectsUnactivatedIdentityStateAsync()
{
using var registryKv = new InMemorySharedKeyValueStore();
var registry = new SharedSingleUseNonceRegistry(registryKv);
using DistributedSessionManager manager = CreateManager(registry);
SharedSessionEntry entry = EntryWithNonce(CreateBytes(32, 13)) with
{
HasActivatedUserIdentity = false
};
DistributedSessionManager.RestoreDecision decision =
await manager.AuthorizeAndConsumeAsync(
entry,
PolicyA,
MessageSecurityMode.SignAndEncrypt,
s_clientChannelCertificate).ConfigureAwait(false);
Assert.That(
decision,
Is.EqualTo(DistributedSessionManager.RestoreDecision.SecurityStateMissing));
}
[Test]
public async Task AuthorizeRejectsDifferentOriginalChannelCertificateAsync()
{
using var registryKv = new InMemorySharedKeyValueStore();
var registry = new SharedSingleUseNonceRegistry(registryKv);
using DistributedSessionManager manager = CreateManager(registry);
SharedSessionEntry entry = EntryWithNonce(CreateBytes(32, 12));
DistributedSessionManager.RestoreDecision decision =
await manager.AuthorizeAndConsumeAsync(
entry,
PolicyA,
MessageSecurityMode.SignAndEncrypt,
ByteString.From(CreateBytes(32, 0x61))).ConfigureAwait(false);
Assert.That(
decision,
Is.EqualTo(
DistributedSessionManager.RestoreDecision.ClientCertificateMismatch));
}
[Test]
public async Task AuthorizeRejectsAllZeroNonceAsync()
{
using var registryKv = new InMemorySharedKeyValueStore();
var registry = new SharedSingleUseNonceRegistry(registryKv);
using DistributedSessionManager manager = CreateManager(registry);
SharedSessionEntry entry = EntryWithNonce(new byte[32]);
DistributedSessionManager.RestoreDecision decision =
await manager.AuthorizeAndConsumeAsync(
entry,
PolicyA,
MessageSecurityMode.SignAndEncrypt,
s_clientChannelCertificate).ConfigureAwait(false);
Assert.That(decision, Is.EqualTo(DistributedSessionManager.RestoreDecision.NonceInvalid));
}
[Test]
public async Task AuthorizeRejectsExpiredSessionAsync()
{
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2026-07-08T12:00:00Z", CultureInfo.InvariantCulture));
using var registryKv = new InMemorySharedKeyValueStore();
var registry = new SharedSingleUseNonceRegistry(registryKv);
using DistributedSessionManager manager = CreateManager(registry, timeProvider: timeProvider);
SharedSessionEntry entry = EntryWithNonce(CreateBytes(32, 4)) with
{
LastActivatedAt = DateTimeUtc.From(timeProvider.GetUtcNow().UtcDateTime.AddMilliseconds(-1001)),
SessionTimeout = 1000
};
DistributedSessionManager.RestoreDecision decision = await manager.AuthorizeAndConsumeAsync(
entry,
PolicyA,
MessageSecurityMode.SignAndEncrypt,
s_clientChannelCertificate).ConfigureAwait(false);
Assert.That(decision, Is.EqualTo(DistributedSessionManager.RestoreDecision.Expired));
}
[Test]
public async Task RestoreRejectsExpiredMirroredSessionAsync()
{
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2026-07-08T12:00:00Z", CultureInfo.InvariantCulture));
using var registryKv = new InMemorySharedKeyValueStore();
using var sessionKv = new InMemorySharedKeyValueStore();
var registry = new SharedSingleUseNonceRegistry(registryKv);
var sessionStore = new SharedKeyValueSessionStore(
sessionKv,
ServiceMessageContext.CreateEmpty(NUnitTelemetryContext.Create()));
using DistributedSessionManager manager = CreateManager(
registry,
sessionStore: sessionStore,
timeProvider: timeProvider);
var authenticationToken = new NodeId("expired-token", 2);
SharedSessionEntry entry = EntryWithNonce(CreateBytes(32, 5)) with
{
AuthenticationToken = authenticationToken,
LastActivatedAt = DateTimeUtc.From(timeProvider.GetUtcNow().UtcDateTime.AddMilliseconds(-1001)),
SessionTimeout = 1000
};
await sessionStore.PutAsync(entry).ConfigureAwait(false);
ISession? session = await InvokeRestoreSessionAsync(
manager,
authenticationToken,
CreateContext()).ConfigureAwait(false);
Assert.That(session, Is.Null);
// Issue #4627 RS-6: an entry nobody can restore any more is removed.
Assert.That(
await sessionStore.TryGetAsync(authenticationToken).ConfigureAwait(false),
Is.Null);
}
[Test]
public void SecureRestoreWithinTimeoutReconstructsClientCertificate()
{
using var registryKv = new InMemorySharedKeyValueStore();
var registry = new SharedSingleUseNonceRegistry(registryKv);
using Certificate serverCertificate = CreateCertificate("CN=DistributedServer");
using Certificate clientCertificate = CreateCertificate("CN=DistributedClient");
Mock server = CreateRestoreServerMock(true);
using DistributedSessionManager manager = CreateManager(
registry,
server: server.Object,
serverCertificateProvider: _ => serverCertificate.AddRef());
SharedSessionEntry entry = CreateSecureEntry(clientCertificate) with
{
LastActivatedAt = DateTimeUtc.Now,
SessionTimeout = 60_000
};
ISession? session = InvokeReconstructSession(
manager,
entry,
entry.AuthenticationToken,
CreateContext(clientCertificate.RawData));
Assert.That(session, Is.Not.Null);
Assert.That(session!.ClientCertificate, Is.Not.Null);
Assert.That(session.ClientCertificate!.Thumbprint, Is.EqualTo(clientCertificate.Thumbprint));
Assert.That(session.EndpointDescription.SecurityMode, Is.EqualTo(MessageSecurityMode.Sign));
Assert.That(session.EndpointDescription.SecurityPolicyUri, Is.EqualTo(PolicyA));
session.Dispose();
}
///
/// PR review 4168294597: a restored Session carries the certificate provenance
/// of the original, so a certificate whose validation error was accepted on the
/// active replica is not a trusted application identity after failover.
///
[TestCase(true)]
[TestCase(false)]
public void RestoredSessionKeepsTheClientCertificateProvenance(bool validated)
{
using var registryKv = new InMemorySharedKeyValueStore();
var registry = new SharedSingleUseNonceRegistry(registryKv);
using Certificate serverCertificate = CreateCertificate("CN=DistributedServer");
using Certificate clientCertificate = CreateCertificate("CN=DistributedClient");
Mock server = CreateRestoreServerMock(true);
using DistributedSessionManager manager = CreateManager(
registry,
server: server.Object,
serverCertificateProvider: _ => serverCertificate.AddRef());
SharedSessionEntry entry = CreateSecureEntry(clientCertificate) with
{
ClientCertificateValidated = validated
};
ISession? session = InvokeReconstructSession(
manager,
entry,
entry.AuthenticationToken,
CreateContext(clientCertificate.RawData));
Assert.That(session, Is.Not.Null);
Assert.That(IsClientCertificateValidated(session!), Is.EqualTo(validated));
session!.Dispose();
}
///
/// PR review 4168294597: an entry written before the provenance was mirrored is
/// still restorable; its certificate is treated as not validated.
///
[Test]
public async Task AuthorizeAcceptsVersionThreeSecurityStateAsync()
{
using var registryKv = new InMemorySharedKeyValueStore();
var registry = new SharedSingleUseNonceRegistry(registryKv);
using DistributedSessionManager manager = CreateManager(registry);
SharedSessionEntry entry = EntryWithNonce(CreateBytes(32, 14)) with
{
SecurityStateVersion = 3
};
DistributedSessionManager.RestoreDecision decision =
await manager.AuthorizeAndConsumeAsync(
entry,
PolicyA,
MessageSecurityMode.SignAndEncrypt,
s_clientChannelCertificate).ConfigureAwait(false);
Assert.That(decision, Is.EqualTo(DistributedSessionManager.RestoreDecision.Authorized));
}
///
/// PR review 4168294597: the provenance is recorded before the base session
/// manager publishes the Session, so the mirror written by the
/// CreateSessionAsync override already sees a certificate whose validation
/// error was accepted as not validated.
///
[TestCase(false)]
[TestCase(true)]
public async Task CreateSessionMirrorsTheClientCertificateProvenanceAsync(bool errorAccepted)
{
using var registryKv = new InMemorySharedKeyValueStore();
using var sessionKv = new InMemorySharedKeyValueStore();
var registry = new SharedSingleUseNonceRegistry(registryKv);
var sessionStore = new SharedKeyValueSessionStore(
sessionKv,
ServiceMessageContext.CreateEmpty(NUnitTelemetryContext.Create()));
using Certificate serverCertificate = CreateCertificate("CN=DistributedServer");
using Certificate clientCertificate = CreateCertificate("CN=DistributedClient");
Mock server = CreateRestoreServerMock(true);
using DistributedSessionManager manager = CreateManager(
registry,
sessionStore: sessionStore,
server: server.Object);
using OperationContext context = CreateContext(clientCertificate.RawData);
typeof(OperationContext)
.GetProperty("ClientCertificateErrorAccepted", BindingFlags.Instance | BindingFlags.NonPublic)!
.SetValue(context, errorAccepted);
Certificate sessionClientCertificate = clientCertificate.AddRef();
CreateSessionResult result;
try
{
result = await manager.CreateSessionAsync(
context,
serverCertificate,
"provenance",
ByteString.From(CreateBytes(32, 15)),
new ApplicationDescription { ApplicationUri = "urn:test:client" },
"opc.tcp://localhost:4840",
sessionClientCertificate,
[],
60_000,
0).ConfigureAwait(false);
}
catch
{
sessionClientCertificate.Dispose();
throw;
}
Assert.That(IsClientCertificateValidated(result.Session), Is.EqualTo(!errorAccepted));
SharedSessionEntry? mirrored = await sessionStore
.TryGetAsync(result.AuthenticationToken)
.ConfigureAwait(false);
Assert.That(mirrored, Is.Not.Null);
Assert.That(mirrored!.ClientCertificateValidated, Is.EqualTo(!errorAccepted));
}
[Test]
public void SecureRestoreWithoutClientCertificateChainIsRejected()
{
using var registryKv = new InMemorySharedKeyValueStore();
var registry = new SharedSingleUseNonceRegistry(registryKv);
using Certificate serverCertificate = CreateCertificate("CN=DistributedServer");
Mock server = CreateRestoreServerMock(true);
using DistributedSessionManager manager = CreateManager(
registry,
server: server.Object,
serverCertificateProvider: _ => serverCertificate.AddRef());
SharedSessionEntry entry = EntryWithNonce(CreateBytes(32, 6)) with
{
SecurityMode = (int)MessageSecurityMode.Sign,
ClientCertificateChain = ByteString.Empty,
SessionTimeout = 60_000
};
ISession? session = InvokeReconstructSession(
manager,
entry,
entry.AuthenticationToken,
CreateContext(s_clientChannelCertificate.ToArray()));
Assert.That(session, Is.Null);
}
[Test]
public async Task FailedSecureRestoreDisposesTemporaryCertificateHandlesAsync()
{
using var registryKv = new InMemorySharedKeyValueStore();
using var sessionKv = new InMemorySharedKeyValueStore();
var registry = new SharedSingleUseNonceRegistry(registryKv);
var sessionStore = new SharedKeyValueSessionStore(
sessionKv,
ServiceMessageContext.CreateEmpty(NUnitTelemetryContext.Create()));
Certificate serverCertificate = CreateCertificate("CN=DistributedServer");
Certificate clientCertificate = CreateCertificate("CN=DistributedClient");
Mock server = CreateRestoreServerMock(false);
string serverThumbprint = serverCertificate.Thumbprint;
string clientThumbprint = clientCertificate.Thumbprint;
try
{
using DistributedSessionManager manager = CreateManager(
registry,
sessionStore: sessionStore,
server: server.Object,
serverCertificateProvider: _ => serverCertificate.AddRef());
SharedSessionEntry entry = CreateSecureEntry(clientCertificate) with
{
SessionTimeout = 60_000
};
await sessionStore.PutAsync(entry).ConfigureAwait(false);
OperationContext context = CreateContext(clientCertificate.RawData);
InvalidOperationException? exception = null;
try
{
_ = await InvokeRestoreSessionAsync(
manager,
entry.AuthenticationToken,
context).ConfigureAwait(false);
}
catch (InvalidOperationException ex)
{
exception = ex;
}
Assert.That(exception, Is.Not.Null);
}
finally
{
serverCertificate.Dispose();
clientCertificate.Dispose();
}
Assert.That(GetCertificateRefCount(serverCertificate), Is.Zero, serverThumbprint);
Assert.That(GetCertificateRefCount(clientCertificate), Is.Zero, clientThumbprint);
}
[Test]
public void ConstructorValidatesArguments()
{
using var registryKv = new InMemorySharedKeyValueStore();
var registry = new SharedSingleUseNonceRegistry(registryKv);
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
IServiceMessageContext context = ServiceMessageContext.CreateEmpty(telemetry);
var serverMock = new Mock();
serverMock.Setup(s => s.Telemetry).Returns(telemetry);
var configuration = new ApplicationConfiguration
{
ServerConfiguration = new ServerConfiguration
{
MinSessionTimeout = 1000,
MaxSessionTimeout = 3_600_000,
MaxSessionCount = 100
}
};
using var kv = new InMemorySharedKeyValueStore();
var store = new SharedKeyValueSessionStore(kv, context);
Assert.That(
() => new DistributedSessionManager(
serverMock.Object, configuration, store, registry, null!),
Throws.ArgumentNullException);
}
///
/// Issue #4627 RS-1: a session in normal use is kept alive by its liveness
/// heartbeat, not only by activations, so it stays restorable long after its
/// last activation.
///
[Test]
public void AuthorizeAcceptsSessionKeptAliveByTheHeartbeat()
{
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2026-10-08T12:00:00Z", CultureInfo.InvariantCulture));
using var registryKv = new InMemorySharedKeyValueStore();
using DistributedSessionManager manager = CreateManager(
new SharedSingleUseNonceRegistry(registryKv),
timeProvider: timeProvider);
DateTime now = timeProvider.GetUtcNow().UtcDateTime;
SharedSessionEntry entry = EntryWithNonce(CreateBytes(32, 30)) with
{
LastActivatedAt = DateTimeUtc.From(now.AddMinutes(-10)),
LastContactAt = DateTimeUtc.From(now.AddSeconds(-5)),
SessionTimeout = 60_000
};
Assert.That(
manager.AuthorizeRestore(entry, PolicyA, MessageSecurityMode.SignAndEncrypt, s_clientChannelCertificate),
Is.EqualTo(DistributedSessionManager.RestoreDecision.Authorized));
}
[Test]
public void AuthorizeRejectsSessionWhoseHeartbeatStopped()
{
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2026-10-08T12:00:00Z", CultureInfo.InvariantCulture));
using var registryKv = new InMemorySharedKeyValueStore();
using DistributedSessionManager manager = CreateManager(
new SharedSingleUseNonceRegistry(registryKv),
timeProvider: timeProvider);
DateTime now = timeProvider.GetUtcNow().UtcDateTime;
// Timeout plus the heartbeat's maximum lag (a quarter of the timeout plus the
// one second liveness interval) has passed since the last mirrored contact.
SharedSessionEntry entry = EntryWithNonce(CreateBytes(32, 31)) with
{
LastActivatedAt = DateTimeUtc.From(now.AddMinutes(-10)),
LastContactAt = DateTimeUtc.From(now.AddMilliseconds(-(60_000 + 15_000 + 1_000))),
SessionTimeout = 60_000
};
Assert.That(
manager.AuthorizeRestore(entry, PolicyA, MessageSecurityMode.SignAndEncrypt, s_clientChannelCertificate),
Is.EqualTo(DistributedSessionManager.RestoreDecision.Expired));
}
///
/// Issue #4627 RS-4: a session whose user token is encrypted with an EphemeralKey
/// is not restored, and nothing is consumed or counted as a failed authentication.
///
[Test]
public async Task AuthorizeRejectsEphemeralKeyUserTokenWithoutConsumingTheNonceAsync()
{
using var registryKv = new InMemorySharedKeyValueStore();
var registry = new SharedSingleUseNonceRegistry(registryKv);
using DistributedSessionManager manager = CreateManager(registry);
SharedSessionEntry entry = EntryWithNonce(CreateBytes(32, 32)) with
{
UserTokenRequiresEphemeralKey = true
};
DistributedSessionManager.RestoreDecision decision = await manager.AuthorizeAndConsumeAsync(
entry,
PolicyA,
MessageSecurityMode.SignAndEncrypt,
s_clientChannelCertificate).ConfigureAwait(false);
Assert.That(decision, Is.EqualTo(DistributedSessionManager.RestoreDecision.EphemeralKeyUnavailable));
Assert.That(await registry.TryConsumeAsync(entry.ServerNonce).ConfigureAwait(false), Is.True);
}
[TestCase(UserTokenType.UserName, null, ExpectedResult = true)]
[TestCase(UserTokenType.IssuedToken, null, ExpectedResult = true)]
[TestCase(UserTokenType.UserName, PolicyA, ExpectedResult = false)]
[TestCase(UserTokenType.Anonymous, null, ExpectedResult = false)]
[TestCase(UserTokenType.Certificate, null, ExpectedResult = false)]
public bool UserTokenRequiresEphemeralKeyOnlyForEncryptedTokensOfEphemeralKeyPolicies(
UserTokenType tokenType,
string? tokenSecurityPolicyUri)
{
if (SecurityPolicies.Default.GetInfo(SecurityPolicies.ECC_nistP256)?.EphemeralKeyAlgorithm is
null or CertificateKeyAlgorithm.None)
{
Assert.Ignore("ECC security policies are not supported on this platform.");
}
var endpoint = new EndpointDescription
{
SecurityPolicyUri = SecurityPolicies.ECC_nistP256,
SecurityMode = MessageSecurityMode.SignAndEncrypt,
UserIdentityTokens = new ArrayOf(new[]
{
new UserTokenPolicy
{
PolicyId = "policy",
TokenType = tokenType,
SecurityPolicyUri = tokenSecurityPolicyUri
}
})
};
var handler = new Mock();
handler.Setup(h => h.TokenType).Returns(tokenType);
handler.Setup(h => h.Token).Returns(new UserNameIdentityToken { PolicyId = "policy" });
var session = new Mock();
session.Setup(s => s.EndpointDescription).Returns(endpoint);
session.Setup(s => s.IdentityToken).Returns(handler.Object);
return DistributedSessionManager.UserTokenRequiresEphemeralKey(session.Object);
}
///
/// Issue #4627 RS-5: a restore materializes the session without consuming the
/// single-use nonce; it is consumed only when the activation passed validation.
///
[Test]
public async Task RestoreDoesNotConsumeTheNonceBeforeTheActivationIsValidatedAsync()
{
using var registryKv = new InMemorySharedKeyValueStore();
using var sessionKv = new InMemorySharedKeyValueStore();
var registry = new SharedSingleUseNonceRegistry(registryKv);
var sessionStore = new SharedKeyValueSessionStore(
sessionKv,
ServiceMessageContext.CreateEmpty(NUnitTelemetryContext.Create()));
using Certificate serverCertificate = CreateCertificate("CN=DistributedServer");
using Certificate clientCertificate = CreateCertificate("CN=DistributedClient");
Mock server = CreateRestoreServerMock(true);
using DistributedSessionManager manager = CreateManager(
registry,
sessionStore: sessionStore,
server: server.Object,
serverCertificateProvider: _ => serverCertificate.AddRef());
SharedSessionEntry entry = CreateSecureEntry(clientCertificate);
await sessionStore.PutAsync(entry).ConfigureAwait(false);
ISession? first = await InvokeRestoreSessionAsync(
manager, entry.AuthenticationToken, CreateContext(clientCertificate.RawData)).ConfigureAwait(false);
ISession? second = await InvokeRestoreSessionAsync(
manager, entry.AuthenticationToken, CreateContext(clientCertificate.RawData)).ConfigureAwait(false);
try
{
Assert.That(first, Is.Not.Null);
Assert.That(second, Is.Not.Null, "a failed or abandoned attempt leaves the session restorable");
Assert.That(await registry.TryConsumeAsync(entry.ServerNonce).ConfigureAwait(false), Is.True);
Assert.That(await sessionStore.TryGetAsync(entry.AuthenticationToken).ConfigureAwait(false), Is.Not.Null);
}
finally
{
first?.Dispose();
second?.Dispose();
}
}
///
/// Issue #4627 RS-3: the restored session asks for the SessionId the client got
/// from CreateSession instead of minting a new one.
///
[Test]
public async Task RestoreRequestsTheMirroredSessionIdAsync()
{
using var registryKv = new InMemorySharedKeyValueStore();
using var sessionKv = new InMemorySharedKeyValueStore();
var sessionStore = new SharedKeyValueSessionStore(
sessionKv,
ServiceMessageContext.CreateEmpty(NUnitTelemetryContext.Create()));
using Certificate serverCertificate = CreateCertificate("CN=DistributedServer");
using Certificate clientCertificate = CreateCertificate("CN=DistributedClient");
NodeId requested = NodeId.Null;
Mock server = CreateRestoreServerMock(true, d => requested = d.SessionId);
using DistributedSessionManager manager = CreateManager(
new SharedSingleUseNonceRegistry(registryKv),
sessionStore: sessionStore,
server: server.Object,
serverCertificateProvider: _ => serverCertificate.AddRef());
SharedSessionEntry entry = CreateSecureEntry(clientCertificate) with
{
SessionId = new NodeId(Guid.NewGuid(), 1)
};
await sessionStore.PutAsync(entry).ConfigureAwait(false);
ISession? session = await InvokeRestoreSessionAsync(
manager, entry.AuthenticationToken, CreateContext(clientCertificate.RawData)).ConfigureAwait(false);
session?.Dispose();
Assert.That(session, Is.Not.Null);
Assert.That(requested, Is.EqualTo(entry.SessionId));
}
///
/// Issue #4627 RS-3: a new session asks for a SessionId that is unique across the
/// replica set (a per-process counter would repeat on every replica).
///
[Test]
public async Task CreateSessionRequestsAClusterUniqueSessionIdAsync()
{
using var registryKv = new InMemorySharedKeyValueStore();
using var sessionKv = new InMemorySharedKeyValueStore();
var sessionStore = new SharedKeyValueSessionStore(
sessionKv,
ServiceMessageContext.CreateEmpty(NUnitTelemetryContext.Create()));
using Certificate serverCertificate = CreateCertificate("CN=DistributedServer");
NodeId requested = NodeId.Null;
Mock server = CreateRestoreServerMock(true, d => requested = d.SessionId);
var namespaces = new NamespaceTable();
int diagnosticsIndex = namespaces.GetIndexOrAppend(Ua.Namespaces.OpcUa + "Diagnostics");
server.Setup(s => s.NamespaceUris).Returns(namespaces);
using DistributedSessionManager manager = CreateManager(
new SharedSingleUseNonceRegistry(registryKv),
sessionStore: sessionStore,
server: server.Object);
await CreateMirroredSessionAsync(manager, serverCertificate, "unique").ConfigureAwait(false);
Assert.That(requested.IdType, Is.EqualTo(IdType.Guid));
Assert.That(requested.NamespaceIndex, Is.EqualTo((ushort)diagnosticsIndex));
}
///
/// Issue #4627 RS-2: the created entry names this replica as its owner and carries
/// the server certificate the client received.
///
[Test]
public async Task CreateSessionMirrorsOwnerAndServerCertificateAsync()
{
using var registryKv = new InMemorySharedKeyValueStore();
using var sessionKv = new InMemorySharedKeyValueStore();
var sessionStore = new SharedKeyValueSessionStore(
sessionKv,
ServiceMessageContext.CreateEmpty(NUnitTelemetryContext.Create()));
using Certificate serverCertificate = CreateCertificate("CN=DistributedServer");
using DistributedSessionManager manager = CreateManager(
new SharedSingleUseNonceRegistry(registryKv),
sessionStore: sessionStore,
server: CreateRestoreServerMock(true).Object);
CreateSessionResult result = await CreateMirroredSessionAsync(manager, serverCertificate, "owner")
.ConfigureAwait(false);
SharedSessionEntry? entry = await sessionStore.TryGetAsync(result.AuthenticationToken).ConfigureAwait(false);
Assert.That(entry, Is.Not.Null);
Assert.That(entry!.OwnerId, Is.EqualTo(manager.ReplicaId));
Assert.That(entry.ServerCertificate, Is.EqualTo(serverCertificate.RawData.ToByteString()));
Assert.That(entry.LastContactAt.IsNull, Is.False);
}
///
/// Issue #4627 RS-8: when the server sends its complete chain in CreateSession the
/// entry carries that blob, which the client may have signed.
///
[Test]
public async Task CreateSessionMirrorsTheAdvertisedServerCertificateChainAsync()
{
using var registryKv = new InMemorySharedKeyValueStore();
using var sessionKv = new InMemorySharedKeyValueStore();
var sessionStore = new SharedKeyValueSessionStore(
sessionKv,
ServiceMessageContext.CreateEmpty(NUnitTelemetryContext.Create()));
using Certificate serverCertificate = CreateCertificate("CN=DistributedServer");
using Certificate issuer = CreateCertificate("CN=DistributedIssuer");
using var chain = new CertificateCollection { serverCertificate, issuer };
ByteString chainBlob = ByteString.From(Utils.CreateCertificateChainBlob(chain));
using DistributedSessionManager manager = CreateManager(
new SharedSingleUseNonceRegistry(registryKv),
sessionStore: sessionStore,
server: CreateRestoreServerMock(true).Object);
CreateSessionResult result = await CreateMirroredSessionAsync(
manager, serverCertificate, "chain", chainBlob).ConfigureAwait(false);
SharedSessionEntry? entry = await sessionStore.TryGetAsync(result.AuthenticationToken).ConfigureAwait(false);
Assert.That(entry!.ServerCertificate, Is.EqualTo(chainBlob));
}
///
/// Issue #4627 RS-2: a replica whose copy of a session was taken over by another
/// replica does not delete the shared entry when that stale copy times out or
/// closes; the owner does.
///
[TestCase(false, ExpectedResult = true)]
[TestCase(true, ExpectedResult = false)]
public async Task CloseDeletesTheEntryOnlyOnTheOwningReplicaAsync(bool owned)
{
using var registryKv = new InMemorySharedKeyValueStore();
using var sessionKv = new InMemorySharedKeyValueStore();
var sessionStore = new SharedKeyValueSessionStore(
sessionKv,
ServiceMessageContext.CreateEmpty(NUnitTelemetryContext.Create()));
using Certificate serverCertificate = CreateCertificate("CN=DistributedServer");
using DistributedSessionManager manager = CreateManager(
new SharedSingleUseNonceRegistry(registryKv),
sessionStore: sessionStore,
server: CreateRestoreServerMock(true).Object);
CreateSessionResult result = await CreateMirroredSessionAsync(manager, serverCertificate, "close")
.ConfigureAwait(false);
if (!owned)
{
SharedSessionEntry entry = (await sessionStore.TryGetAsync(result.AuthenticationToken).ConfigureAwait(false))!;
await sessionStore.PutAsync(entry with { OwnerId = "another-replica" }).ConfigureAwait(false);
}
await manager.CloseSessionAsync(result.SessionId).ConfigureAwait(false);
return await sessionStore.TryGetAsync(result.AuthenticationToken).ConfigureAwait(false) != null;
}
///
/// Issue #4627 RS-2: an activation that committed on this replica records it as the
/// owner, together with the liveness of the activation.
///
[Test]
public async Task MirroredActivationRecordsTheOwnerAsync()
{
using var sessionKv = new InMemorySharedKeyValueStore();
var sessionStore = new SharedKeyValueSessionStore(
sessionKv,
ServiceMessageContext.CreateEmpty(NUnitTelemetryContext.Create()));
using var registryKv = new InMemorySharedKeyValueStore();
using DistributedSessionManager manager = CreateManager(
new SharedSingleUseNonceRegistry(registryKv),
sessionStore);
SharedSessionEntry entry = EntryWithNonce(CreateBytes(32, 33)) with { OwnerId = "another-replica" };
await sessionStore.PutAsync(entry).ConfigureAwait(false);
await manager.MirrorActivationIfCurrentAsync(
entry.AuthenticationToken,
ByteString.From(CreateBytes(32, 34)),
UserTokenType.Anonymous,
null,
activationSequence: 1,
userTokenRequiresEphemeralKey: true,
CancellationToken.None).ConfigureAwait(false);
SharedSessionEntry? stored = await sessionStore.TryGetAsync(entry.AuthenticationToken).ConfigureAwait(false);
Assert.That(stored!.OwnerId, Is.EqualTo(manager.ReplicaId));
Assert.That(stored.LastContactAt.IsNull, Is.False);
Assert.That(stored.UserTokenRequiresEphemeralKey, Is.True);
}
///
/// Issue #4627 RS-6: the sweep removes the entries of sessions that can no longer
/// be restored and the consumed-nonce markers no live entry references.
///
[Test]
public async Task SweepRemovesExpiredEntriesAndOrphanedNonceMarkersAsync()
{
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2026-10-08T12:00:00Z", CultureInfo.InvariantCulture));
using var registryKv = new InMemorySharedKeyValueStore();
using var sessionKv = new InMemorySharedKeyValueStore();
var registry = new SharedSingleUseNonceRegistry(registryKv, timeProvider: timeProvider);
var sessionStore = new SharedKeyValueSessionStore(
sessionKv,
ServiceMessageContext.CreateEmpty(NUnitTelemetryContext.Create()));
using DistributedSessionManager manager = CreateManager(
registry,
sessionStore: sessionStore,
timeProvider: timeProvider);
SharedSessionEntry expired = EntryWithNonce(CreateBytes(32, 40)) with
{
AuthenticationToken = new NodeId("expired", 2),
LastActivatedAt = DateTimeUtc.From(timeProvider.GetUtcNow().UtcDateTime),
SessionTimeout = 60_000
};
Assert.That(await registry.TryConsumeAsync(expired.ServerNonce).ConfigureAwait(false), Is.True);
ByteString liveNonce = ByteString.From(CreateBytes(32, 41));
Assert.That(await registry.TryConsumeAsync(liveNonce).ConfigureAwait(false), Is.True);
timeProvider.Advance(TimeSpan.FromHours(2));
SharedSessionEntry live = EntryWithNonce(CreateBytes(32, 41)) with
{
AuthenticationToken = new NodeId("live", 2),
LastActivatedAt = DateTimeUtc.From(timeProvider.GetUtcNow().UtcDateTime),
SessionTimeout = 60_000
};
await sessionStore.PutAsync(expired).ConfigureAwait(false);
await sessionStore.PutAsync(live).ConfigureAwait(false);
(int entries, int markers) = await manager.SweepAsync(CancellationToken.None).ConfigureAwait(false);
Assert.That(entries, Is.EqualTo(1));
Assert.That(markers, Is.EqualTo(1));
Assert.That(await sessionStore.TryGetAsync(expired.AuthenticationToken).ConfigureAwait(false), Is.Null);
Assert.That(await sessionStore.TryGetAsync(live.AuthenticationToken).ConfigureAwait(false), Is.Not.Null);
Assert.That(
await registry.TryConsumeAsync(liveNonce).ConfigureAwait(false),
Is.False,
"the marker of a nonce a live entry carries is kept");
}
private static async Task CreateMirroredSessionAsync(
DistributedSessionManager manager,
Certificate serverCertificate,
string name,
ByteString advertisedServerCertificate = default)
{
using Certificate clientCertificate = CreateCertificate("CN=DistributedClient");
using OperationContext context = CreateContext(clientCertificate.RawData, advertisedServerCertificate);
Certificate sessionClientCertificate = clientCertificate.AddRef();
try
{
return await manager.CreateSessionAsync(
context,
serverCertificate,
name,
ByteString.From(CreateBytes(32, (byte)name.Length)),
new ApplicationDescription { ApplicationUri = "urn:test:client" },
"opc.tcp://localhost:4840",
sessionClientCertificate,
[],
60_000,
0).ConfigureAwait(false);
}
catch
{
sessionClientCertificate.Dispose();
throw;
}
}
private static SharedSessionEntry CreateSecureEntry(Certificate clientCertificate)
{
using var clientChain = new CertificateCollection { clientCertificate };
return new SharedSessionEntry
{
SessionId = new NodeId(1, 1),
AuthenticationToken = new NodeId("secure-token", 2),
SessionName = "secure-session",
CreatedAt = DateTimeUtc.Now,
LastActivatedAt = DateTimeUtc.Now,
ServerNonce = ByteString.From(CreateBytes(32, 7)),
ClientNonce = ByteString.From(new byte[] { 5, 6, 7, 8 }),
ClientCertificateChain = ByteString.From(Utils.CreateCertificateChainBlob(clientChain)),
SecurityStateVersion = SharedSessionEntry.CurrentSecurityStateVersion,
OriginalClientChannelCertificate = clientCertificate.RawData.ToByteString(),
ClientUserId = null,
ClientUserTokenType = UserTokenType.Anonymous,
HasActivatedUserIdentity = true,
SecurityPolicyUri = PolicyA,
SecurityMode = (int)MessageSecurityMode.Sign,
EndpointUrl = "opc.tcp://localhost:4840",
SessionTimeout = 60_000,
ClientDescription = new ApplicationDescription
{
ApplicationUri = "urn:test:client",
ApplicationName = new LocalizedText("DistributedSessionManagerTests"),
ApplicationType = ApplicationType.Client
}
};
}
private static Certificate CreateCertificate(string subject)
{
return CertificateBuilder
.Create(subject)
.SetRSAKeySize(CertificateFactory.DefaultKeySize)
.CreateForRSA();
}
private static OperationContext CreateContext(
byte[]? clientChannelCertificate = null,
ByteString serverCertificate = default)
{
var endpoint = new EndpointDescription
{
EndpointUrl = "opc.tcp://localhost:4840",
SecurityPolicyUri = PolicyA,
SecurityMode = MessageSecurityMode.Sign,
ServerCertificate = serverCertificate
};
var channelContext = new SecureChannelContext(
"restore-channel",
endpoint,
RequestEncoding.Binary,
clientChannelCertificate,
null,
null);
return new OperationContext(
new RequestHeader(),
channelContext,
RequestType.ActivateSession,
RequestLifetime.None);
}
private static Mock CreateRestoreServerMock(
bool initializeSucceeds,
Action? onCreateDiagnostics = null)
{
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
var serverMock = new Mock();
serverMock.Setup(s => s.Telemetry).Returns(telemetry);
serverMock.Setup(s => s.NamespaceUris).Returns(new NamespaceTable());
serverMock.Setup(s => s.SubscriptionStore).Returns((ISubscriptionStore)null!);
var diagnosticsNodeManager = new Mock();
if (initializeSucceeds)
{
diagnosticsNodeManager
.Setup(m => m.CreateSessionDiagnosticsAsync(
It.IsAny(),
It.IsAny(),
It.IsAny(),
It.IsAny(),
It.IsAny(),
It.IsAny()))
.Callback(
(_, diagnostics, _, _, _, _) => onCreateDiagnostics?.Invoke(diagnostics))
.Returns(new ValueTask(new NodeId(5001, 1)));
}
else
{
diagnosticsNodeManager
.Setup(m => m.CreateSessionDiagnosticsAsync(
It.IsAny(),
It.IsAny(),
It.IsAny(),
It.IsAny(),
It.IsAny(),
It.IsAny()))
.Returns(
new ValueTask(
Task.FromException(
new InvalidOperationException("diagnostics unavailable"))));
}
serverMock.Setup(s => s.DiagnosticsNodeManager).Returns(diagnosticsNodeManager.Object);
serverMock.Setup(s => s.DefaultSystemContext).Returns(new ServerSystemContext(serverMock.Object));
return serverMock;
}
private static ISession? InvokeReconstructSession(
DistributedSessionManager manager,
SharedSessionEntry entry,
NodeId authenticationToken,
OperationContext context)
{
MethodInfo method = typeof(DistributedSessionManager).GetMethod(
"ReconstructSession",
BindingFlags.Instance | BindingFlags.NonPublic)!;
return (ISession?)method.Invoke(manager, [entry, authenticationToken, context]);
}
private static async Task InvokeRestoreSessionAsync(
DistributedSessionManager manager,
NodeId authenticationToken,
OperationContext context)
{
MethodInfo method = typeof(DistributedSessionManager).GetMethod(
"RestoreSessionAsync",
BindingFlags.Instance | BindingFlags.NonPublic)!;
var task = (ValueTask)method.Invoke(manager, [authenticationToken, context, default(CancellationToken)])!;
return await task.ConfigureAwait(false);
}
private static bool IsClientCertificateValidated(ISession session)
{
MethodInfo method = typeof(SessionManager).Assembly
.GetType("Opc.Ua.Server.ClientCertificateProvenance", throwOnError: true)!
.GetMethod("IsValidated", BindingFlags.Static | BindingFlags.Public)!;
return (bool)method.Invoke(null, [session])!;
}
private static int GetCertificateRefCount(Certificate certificate)
{
object core = typeof(Certificate)
.GetField("m_core", BindingFlags.Instance | BindingFlags.NonPublic)!
.GetValue(certificate)!;
return (int)core.GetType().GetProperty("RefCount", BindingFlags.Instance | BindingFlags.Public)!
.GetValue(core)!;
}
private static byte[] CreateBytes(int length, byte seed)
{
byte[] bytes = new byte[length];
for (int i = 0; i < bytes.Length; i++)
{
bytes[i] = (byte)(seed + i);
}
return bytes;
}
}
}