Files
UA-.NETStandard/tests/Opc.Ua.Server.Tests/SessionBindingTests.cs
romanett 9b034f8291 Enable nullable reference types centrally; migrate all unset and annotations-only projects (#4700)
# 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>
2026-10-08 08:48:13 +02:00

833 lines
41 KiB
C#

/* ========================================================================
* Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
*
* OPC Foundation MIT License 1.00
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*
* The complete license agreement can be found here:
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Time.Testing;
using Moq;
using NUnit.Framework;
using Opc.Ua.Security.Certificates;
using Opc.Ua.Tests;
namespace Opc.Ua.Server.Tests
{
[TestFixture]
[Category("Session")]
public sealed class SessionBindingTests
{
[SetUp]
public void SetUp()
{
m_clock = new CallbackTimeProvider();
m_certificate = CertificateBuilder.Create("CN=SessionBindingTests")
.SetRSAKeySize(2048).CreateForRSA();
var server = new Mock<IServerInternal>();
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.DefaultSystemContext).Returns(new ServerSystemContext(server.Object));
m_diagnostics = new Mock<IDiagnosticsNodeManager>();
uint nextSessionId = 0;
m_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(++nextSessionId, 1)));
server.Setup(s => s.DiagnosticsNodeManager).Returns(m_diagnostics.Object);
m_manager = new BindingSessionManager(server.Object, m_certificate, m_clock);
server.Setup(s => s.SessionManager).Returns(m_manager);
server.Setup(s => s.CloseSessionAsync(
It.IsAny<OperationContext>(), It.IsAny<NodeId>(), It.IsAny<bool>(),
It.IsAny<CancellationToken>()))
.Returns((OperationContext _, NodeId id, bool _, CancellationToken ct) =>
m_manager.CloseSessionAsync(id, ct));
}
[TearDown]
public void TearDown()
{
foreach (OperationContext context in m_contexts)
{
context.Dispose();
}
m_contexts.Clear();
m_manager.Dispose();
m_certificate.Dispose();
}
[Test]
public async Task RepeatedActivationIsDistinctWithoutActivationEventAsync()
{
OperationContext channel = CreateContext("one");
CreateSessionResult created = await CreateAsync(channel).ConfigureAwait(false);
Assert.That(m_manager.HasSession("one"), Is.False);
int events = 0;
m_manager.SessionActivated += (_, _) => events++;
await ActivateAsync(created, channel).ConfigureAwait(false);
SessionBindingContext first = GetBinding(created, channel);
int firstEvents = events;
await ActivateAsync(created, channel).ConfigureAwait(false);
SessionBindingContext second = GetBinding(created, channel);
Assert.That(events, Is.EqualTo(firstEvents), "Same identity and locales do not raise the event.");
Assert.That(second.ActivationSequence, Is.EqualTo(first.ActivationSequence + 1));
Assert.That(first.ActivationSequence, Is.EqualTo(1));
Assert.That(first.UserTokenType, Is.EqualTo(UserTokenType.Anonymous));
Assert.That(first.ClientUserId, Is.Null);
Assert.That(m_manager.HasSession("one"), Is.True);
await m_manager.CloseSessionAsync(created.SessionId).ConfigureAwait(false);
Assert.That(m_manager.HasSession("one"), Is.False, "One close must remove repeated activations.");
AssertNoBinding(created.AuthenticationToken, channel);
}
[Test]
public async Task TransferPreservesOtherSessionsAndInvalidatesOldContextAsync()
{
OperationContext oldChannel = CreateContext("old");
OperationContext newChannel = CreateContext("new");
CreateSessionResult first = await CreateAsync(oldChannel).ConfigureAwait(false);
CreateSessionResult second = await CreateAsync(oldChannel).ConfigureAwait(false);
await ActivateAsync(first, oldChannel).ConfigureAwait(false);
await ActivateAsync(second, oldChannel).ConfigureAwait(false);
SessionBindingContext original = GetBinding(first, oldChannel);
await ActivateAsync(first, newChannel).ConfigureAwait(false);
SessionBindingContext transferred = GetBinding(first, newChannel);
AssertNoBinding(first.AuthenticationToken, oldChannel);
Assert.That(transferred.SessionId, Is.EqualTo(first.SessionId));
Assert.That(transferred.ActivationSequence, Is.EqualTo(original.ActivationSequence + 1));
Assert.That(original.SecureChannelId, Is.EqualTo("old"), "Snapshots must not change on transfer.");
Assert.That(GetBinding(second, oldChannel).SessionId, Is.EqualTo(second.SessionId));
Assert.That(m_manager.HasSession("old"), Is.True);
Assert.That(m_manager.HasSession("new"), Is.True);
await m_manager.CloseSessionAsync(second.SessionId).ConfigureAwait(false);
Assert.That(m_manager.HasSession("old"), Is.False);
Assert.That(m_manager.HasSession("new"), Is.True);
await m_manager.CloseSessionAsync(first.SessionId).ConfigureAwait(false);
await m_manager.CloseSessionAsync(first.SessionId).ConfigureAwait(false);
Assert.That(m_manager.HasSession("new"), Is.False);
}
[Test]
public async Task FailedActivationDoesNotPublishOrMoveMembershipAsync()
{
OperationContext oldChannel = CreateContext("old");
OperationContext newChannel = CreateContext("new");
CreateSessionResult created = await CreateAsync(oldChannel).ConfigureAwait(false);
ServiceResultException error = Assert.ThrowsAsync<ServiceResultException>(
() => ActivateAsync(created, newChannel))!;
Assert.That(error.StatusCode, Is.EqualTo(StatusCodes.BadSecureChannelIdInvalid));
Assert.That(m_manager.HasSession("old"), Is.False);
Assert.That(m_manager.HasSession("new"), Is.False);
AssertNoBinding(created.AuthenticationToken, oldChannel);
await ActivateAsync(created, oldChannel).ConfigureAwait(false);
SessionBindingContext original = GetBinding(created, oldChannel);
m_manager.RejectAuthentication = true;
error = Assert.ThrowsAsync<ServiceResultException>(() => ActivateAsync(created, newChannel))!;
Assert.That(error.StatusCode, Is.EqualTo(StatusCodes.BadUserAccessDenied));
Assert.That(GetBinding(created, oldChannel), Is.SameAs(original));
Assert.That(m_manager.HasSession("old"), Is.True);
Assert.That(m_manager.HasSession("new"), Is.False);
}
/// <summary>
/// AS-2: once the activation committed the session holds the new serverNonce,
/// so a failing or cancelled post-commit callback must not turn the response
/// into a fault that hides the nonce from the client (Part 4 5.7.3.1).
/// </summary>
[Test]
public async Task FailingActivatedCallbackStillReturnsTheCommittedNonceAsync()
{
OperationContext channel = CreateContext("one");
CreateSessionResult created = await CreateAsync(channel).ConfigureAwait(false);
await ActivateAsync(created, channel).ConfigureAwait(false);
SessionBindingContext first = GetBinding(created, channel);
m_manager.CallbackException = new OperationCanceledException("Injected mirror timeout.");
(_, ByteString serverNonce, ServiceResult status) = await m_manager.ActivateSessionAsync(
channel, created.AuthenticationToken, null, default, null, []).ConfigureAwait(false);
Assert.That(ServiceResult.IsGood(status), Is.True);
Assert.That(serverNonce.IsEmpty, Is.False);
Assert.That(GetBinding(created, channel).ActivationSequence, Is.EqualTo(first.ActivationSequence + 1));
Assert.That(m_manager.LastCompletedCallbackSequence, Is.EqualTo(first.ActivationSequence + 1));
}
[Test]
public async Task ReadOnlyLookupValidatesTokenChannelAndSecurityWithoutRefreshingActivityAsync()
{
OperationContext channel = CreateContext("one");
CreateSessionResult created = await CreateAsync(channel).ConfigureAwait(false);
AssertNoBinding(created.AuthenticationToken, channel);
AssertNoBinding(NodeId.Null, channel);
AssertNoBinding(new NodeId("unknown", 1), channel);
await ActivateAsync(created, channel).ConfigureAwait(false);
SessionBindingContext binding = GetBinding(created, channel);
long contact = created.Session.LastContactTickCount;
uint requestCount = created.Session.ReadDiagnostics(d => d.TotalRequestCount.TotalCount);
m_clock.Advance(TimeSpan.FromSeconds(1));
OperationContext otherChannel = CreateContext("other");
OperationContext otherMode = CreateContext("one", MessageSecurityMode.Sign);
OperationContext otherPolicy = CreateContext("one", policy: SecurityPolicies.Basic256Sha256);
OperationContext otherCertificate = CreateContext("one", certificate: [1, 2, 3]);
AssertNoBinding(created.AuthenticationToken, otherChannel);
AssertNoBinding(created.AuthenticationToken, otherMode);
AssertNoBinding(created.AuthenticationToken, otherPolicy);
Assert.That(GetBinding(created, otherCertificate), Is.SameAs(binding));
Assert.That(m_manager.TryGetSessionContext(
created.AuthenticationToken, new SecureChannelContext("one", null, RequestEncoding.Binary),
out SessionBindingContext? missingEndpoint), Is.False);
Assert.That(missingEndpoint, Is.Null);
Assert.That(GetBinding(created, channel), Is.SameAs(binding));
Assert.That(created.Session.LastContactTickCount, Is.EqualTo(contact));
Assert.That(created.Session.ReadDiagnostics(d => d.TotalRequestCount.TotalCount), Is.EqualTo(requestCount));
}
[TestCase("opc.tcp://localhost:4840/binding")]
[TestCase("opc.https://localhost:4843/binding")]
public async Task NonePolicyLookupIgnoresOptionalChannelCertificateAsync(string endpointUrl)
{
OperationContext channel = CreateContext("shared", certificate: [1, 2], endpointUrl: endpointUrl);
CreateSessionResult created = await CreateAsync(channel).ConfigureAwait(false);
await ActivateAsync(created, channel).ConfigureAwait(false);
SessionBindingContext original = GetBinding(created, channel);
byte[]?[] certificates = [null, [], [3, 4]];
foreach (byte[]? certificate in certificates)
{
OperationContext request = CreateContext(
"shared", certificate: certificate, endpointUrl: endpointUrl);
Assert.That(GetBinding(created, request), Is.SameAs(original));
using OperationContext validated = await m_manager.ValidateRequestAsync(
new RequestHeader { AuthenticationToken = created.AuthenticationToken },
request.ChannelContext!, RequestType.Read, RequestLifetime.None).ConfigureAwait(false);
Assert.That(validated.Session, Is.SameAs(created.Session));
}
Assert.That(original.UserTokenType, Is.EqualTo(UserTokenType.Anonymous));
Assert.That(original.ClientUserId, Is.Null);
}
/// <summary>
/// Part 6 7.4.1: every HTTPS client of a listener shares one SecureChannelId,
/// so the AuthenticationToken must be a random value of at least 32 bytes.
/// A sequential token let a second HTTPS client derive the token of the
/// session created just before its own and use that session.
/// </summary>
[Test]
public async Task SharedSecuredChannelGetsRandomTokenThatCannotBeGuessedAsync()
{
OperationContext victimChannel = CreateContext(
"https-listener", MessageSecurityMode.SignAndEncrypt, SecurityPolicies.Basic256Sha256,
endpointUrl: "opc.https://localhost:4843/binding");
OperationContext attackerChannel = CreateContext(
"https-listener", MessageSecurityMode.SignAndEncrypt, SecurityPolicies.Basic256Sha256,
endpointUrl: "opc.https://localhost:4843/binding");
CreateSessionResult victim = await CreateAsync(victimChannel).ConfigureAwait(false);
CreateSessionResult attacker = await CreateAsync(attackerChannel).ConfigureAwait(false);
if (attacker.AuthenticationToken.TryGetValue(out uint own))
{
// the attacker derives the neighbouring tokens from its own one.
Assert.That(m_manager.GetSession(new NodeId(own - 1)), Is.Null,
"A token derived from another client's token must not find its session.");
}
foreach (CreateSessionResult created in new[] { victim, attacker })
{
Assert.That(created.AuthenticationToken.TryGetValue(out ByteString token), Is.True,
"The token on a shared channel must be a random opaque value.");
Assert.That(token.Length, Is.GreaterThanOrEqualTo(32));
}
Assert.That(victim.AuthenticationToken, Is.Not.EqualTo(attacker.AuthenticationToken));
}
[Test]
public async Task ReadOnlyLookupDoesNotHoldIndexLockWhileProbingSessionAsync()
{
OperationContext channel = CreateContext("one");
CreateSessionResult created = await CreateAsync(channel).ConfigureAwait(false);
await ActivateAsync(created, channel).ConfigureAwait(false);
var entered = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
using var release = new ManualResetEventSlim();
m_clock.OnNextTimestamp(() =>
{
entered.SetResult(true);
release.Wait();
});
Task<bool> lookup = Task.Run(() => m_manager.TryGetSessionContext(
created.AuthenticationToken, channel.ChannelContext!, out _));
Task<bool>? membership = null;
try
{
await entered.Task.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false);
membership = Task.Run(() => m_manager.HasSession("one"));
Assert.That(await membership.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false), Is.True);
}
finally
{
release.Set();
await lookup.ConfigureAwait(false);
if (membership != null)
{
await membership.ConfigureAwait(false);
}
}
Assert.That(await lookup.ConfigureAwait(false), Is.True);
}
[Test]
public async Task ReadOnlyLookupRechecksMembershipAfterProbingSessionAsync()
{
OperationContext channel = CreateContext("one");
CreateSessionResult created = await CreateAsync(channel).ConfigureAwait(false);
await ActivateAsync(created, channel).ConfigureAwait(false);
Task close = Task.CompletedTask;
m_clock.OnNextTimestamp(() => close = m_manager.CloseSessionAsync(created.SessionId).AsTask());
bool classified = m_manager.TryGetSessionContext(
created.AuthenticationToken, channel.ChannelContext!, out SessionBindingContext? binding);
await close.ConfigureAwait(false);
Assert.That(classified, Is.False);
Assert.That(binding, Is.Null);
Assert.That(m_manager.HasSession("one"), Is.False);
}
[Test]
public async Task ReadOnlyLookupRejectsVersionChangedDuringSessionProbeAsync()
{
OperationContext channel = CreateContext("one");
CreateSessionResult created = await CreateAsync(channel).ConfigureAwait(false);
await ActivateAsync(created, channel).ConfigureAwait(false);
SessionBindingContext original = GetBinding(created, channel);
Task activation = Task.CompletedTask;
m_clock.OnNextTimestamp(() => activation = ActivateAsync(created, channel));
bool classified = m_manager.TryGetSessionContext(
created.AuthenticationToken, channel.ChannelContext!, out SessionBindingContext? binding);
await activation.ConfigureAwait(false);
Assert.That(classified, Is.False);
Assert.That(binding, Is.Null);
Assert.That(GetBinding(created, channel).ActivationSequence, Is.EqualTo(original.ActivationSequence + 1));
Assert.That(m_manager.HasSession("one"), Is.True);
}
[Test]
public async Task ExpiredSessionLosesClassificationAndTimeoutRemovesMembershipAsync()
{
OperationContext channel = CreateContext("one");
CreateSessionResult created = await CreateAsync(channel).ConfigureAwait(false);
await ActivateAsync(created, channel).ConfigureAwait(false);
m_clock.Advance(TimeSpan.FromMilliseconds(60_001));
AssertNoBinding(created.AuthenticationToken, channel);
ServiceResultException error = Assert.ThrowsAsync<ServiceResultException>(
() => ActivateAsync(created, channel))!;
Assert.That(error.StatusCode, Is.EqualTo(StatusCodes.BadSessionClosed));
Assert.That(m_manager.HasSession("one"), Is.False);
Assert.That(m_manager.GetSession(created.AuthenticationToken), Is.Null);
}
[Test]
public async Task AdministrativeCloseRemovesBindingBeforeCallbacksEvenWhenCleanupFailsAsync()
{
OperationContext channel = CreateContext("one");
CreateSessionResult created = await CreateAsync(channel).ConfigureAwait(false);
await ActivateAsync(created, channel).ConfigureAwait(false);
bool? membershipAtClose = null;
m_manager.SessionClosing += (_, _) => membershipAtClose = m_manager.HasSession("one");
m_diagnostics.Setup(d => d.DeleteSessionDiagnosticsAsync(
It.IsAny<ServerSystemContext>(), It.IsAny<NodeId>(), It.IsAny<CancellationToken>()))
.Throws(new InvalidOperationException("Injected diagnostics cleanup failure."));
Assert.ThrowsAsync<InvalidOperationException>(
async () => await m_manager.CloseSessionAsync(created.SessionId).ConfigureAwait(false));
Assert.That(membershipAtClose, Is.False);
Assert.That(m_manager.HasSession("one"), Is.False);
AssertNoBinding(created.AuthenticationToken, channel);
}
[Test]
public async Task DelayedActivationCallbackCannotOverwriteTransferOrResurrectClosedSessionAsync()
{
OperationContext oldChannel = CreateContext("old");
OperationContext newChannel = CreateContext("new");
CreateSessionResult created = await CreateAsync(oldChannel).ConfigureAwait(false);
m_manager.DelayNextCallback();
Task first = ActivateAsync(created, oldChannel);
await m_manager.CallbackEntered.Task.ConfigureAwait(false);
try
{
Assert.That(GetBinding(created, oldChannel).ActivationSequence, Is.EqualTo(1));
await ActivateAsync(created, newChannel).ConfigureAwait(false);
Assert.That(m_manager.HasSession("old"), Is.False);
Assert.That(GetBinding(created, newChannel).ActivationSequence, Is.EqualTo(2));
await m_manager.CloseSessionAsync(created.SessionId).ConfigureAwait(false);
Assert.That(m_manager.HasSession("new"), Is.False);
}
finally
{
m_manager.ReleaseCallback.TrySetResult(true);
await first.ConfigureAwait(false);
}
Assert.That(m_manager.LastCompletedCallbackSequence, Is.EqualTo(1));
Assert.That(m_manager.HasSession("old"), Is.False);
Assert.That(m_manager.HasSession("new"), Is.False);
AssertNoBinding(created.AuthenticationToken, newChannel);
}
[Test]
public async Task CloseRacingTransferRemovesOnlyCommittedBindingAsync()
{
OperationContext oldChannel = CreateContext("old");
OperationContext newChannel = CreateContext("new");
CreateSessionResult created = await CreateAsync(oldChannel).ConfigureAwait(false);
await ActivateAsync(created, oldChannel).ConfigureAwait(false);
m_manager.DelayNextAuthentication();
Task transfer = ActivateAsync(created, newChannel);
await m_manager.AuthenticationEntered.Task.ConfigureAwait(false);
Task close = m_manager.CloseSessionAsync(created.SessionId).AsTask();
try
{
Assert.That(close.IsCompleted, Is.False);
Assert.That(GetBinding(created, oldChannel).ActivationSequence, Is.EqualTo(1));
Assert.That(m_manager.HasSession("new"), Is.False);
}
finally
{
m_manager.ReleaseAuthentication.TrySetResult(true);
await Task.WhenAll(transfer, close).ConfigureAwait(false);
}
Assert.That(m_manager.HasSession("old"), Is.False);
Assert.That(m_manager.HasSession("new"), Is.False);
AssertNoBinding(created.AuthenticationToken, newChannel);
}
[Test]
public async Task ShutdownDuringActivationCannotPublishLateMembershipAsync()
{
OperationContext channel = CreateContext("one");
CreateSessionResult created = await CreateAsync(channel).ConfigureAwait(false);
await ActivateAsync(created, channel).ConfigureAwait(false);
m_manager.DelayNextAuthentication();
Task activation = ActivateAsync(created, channel);
await m_manager.AuthenticationEntered.Task.ConfigureAwait(false);
await m_manager.ShutdownAsync().ConfigureAwait(false);
Assert.That(m_manager.HasSession("one"), Is.False);
m_manager.ReleaseAuthentication.TrySetResult(true);
ServiceResultException error = Assert.ThrowsAsync<ServiceResultException>(() => activation)!;
Assert.That(error.StatusCode, Is.EqualTo(StatusCodes.BadSessionClosed));
Assert.That(m_manager.HasSession("one"), Is.False);
AssertNoBinding(created.AuthenticationToken, channel);
}
[Test]
public async Task RestoredSessionGainsMembershipOnlyAfterSuccessfulActivationAsync()
{
OperationContext channel = CreateContext("restored");
var authenticationToken = new NodeId("restored-token", 1);
AssertNoBinding(authenticationToken, channel);
m_manager.RestoreEnabled = true;
m_manager.RejectAuthentication = true;
ServiceResultException error = Assert.ThrowsAsync<ServiceResultException>(
() => ActivateTokenAsync(authenticationToken, channel))!;
Assert.That(error.StatusCode, Is.EqualTo(StatusCodes.BadUserAccessDenied));
Assert.That(m_manager.HasSession("restored"), Is.False);
AssertNoBinding(authenticationToken, channel);
m_manager.RejectAuthentication = false;
await ActivateTokenAsync(authenticationToken, channel).ConfigureAwait(false);
Assert.That(m_manager.TryGetSessionContext(authenticationToken, channel.ChannelContext!, out var binding),
Is.True);
Assert.That(binding!.ActivationSequence, Is.EqualTo(1));
Assert.That(m_manager.HasSession("restored"), Is.True);
await m_manager.CloseSessionAsync(binding.SessionId).ConfigureAwait(false);
Assert.That(m_manager.HasSession("restored"), Is.False);
AssertNoBinding(authenticationToken, channel);
}
[Test]
public async Task IdentityChangePublishesNewVersionWithoutMutatingExistingSnapshotAsync()
{
OperationContext channel = CreateContext("one");
CreateSessionResult created = await CreateAsync(channel).ConfigureAwait(false);
await ActivateUserAsync("alice").ConfigureAwait(false);
SessionBindingContext alice = GetBinding(created, channel);
await ActivateUserAsync("bob").ConfigureAwait(false);
SessionBindingContext bob = GetBinding(created, channel);
Assert.That(alice.UserTokenType, Is.EqualTo(UserTokenType.UserName));
Assert.That(alice.ClientUserId, Is.EqualTo("1:-1:alice"));
Assert.That(bob.UserTokenType, Is.EqualTo(UserTokenType.UserName));
Assert.That(bob.ClientUserId, Is.EqualTo("1:-1:bob"));
Assert.That(bob.ActivationSequence, Is.EqualTo(alice.ActivationSequence + 1));
await m_manager.CloseSessionAsync(created.SessionId).ConfigureAwait(false);
Assert.That(m_manager.HasSession("one"), Is.False);
async Task ActivateUserAsync(string userName)
{
await m_manager.ActivateSessionAsync(
channel, created.AuthenticationToken, null,
new ExtensionObject(new UserNameIdentityToken
{
PolicyId = "username",
UserName = userName,
Password = ByteString.From([1])
}),
null, []).ConfigureAwait(false);
}
}
[Test]
public async Task FailedReactivationKeepsImpersonationMappingForRoleReevaluationAsync()
{
OperationContext channel = CreateContext("one");
CreateSessionResult created = await CreateAsync(channel).ConfigureAwait(false);
m_manager.GrantImpersonatedOperatorRole = true;
await ActivateUserAsync().ConfigureAwait(false);
Assert.That(created.Session.EffectiveIdentity.GrantedRoleIds.Contains(ObjectIds.WellKnownRole_Operator),
Is.True);
// A re-activation that authenticates (creating a new identity object) but then
// fails while adding mandatory roles must not replace the mapping of the identity
// that stays active.
m_manager.ThrowFromMandatoryRoles = true;
Assert.CatchAsync(ActivateUserAsync);
m_manager.ThrowFromMandatoryRoles = false;
created.Session.MarkIdentityStale();
m_manager.PublicReevaluateIdentityIfStale(created.Session, channel.ChannelContext!);
Assert.That(created.Session.EffectiveIdentity.GrantedRoleIds.Contains(ObjectIds.WellKnownRole_Operator),
Is.True, "Re-evaluation must start from the impersonated identity of the active activation.");
await m_manager.CloseSessionAsync(created.SessionId).ConfigureAwait(false);
async Task ActivateUserAsync()
{
await m_manager.ActivateSessionAsync(
channel, created.AuthenticationToken, null,
new ExtensionObject(new UserNameIdentityToken
{
PolicyId = "username",
UserName = "alice",
Password = ByteString.From([1])
}),
null, []).ConfigureAwait(false);
}
}
[Test]
public async Task ClosingSessionCannotBeClassifiedAsync()
{
OperationContext channel = CreateContext("one");
CreateSessionResult created = await CreateAsync(channel).ConfigureAwait(false);
await ActivateAsync(created, channel).ConfigureAwait(false);
Assert.That(((Server.Session)created.Session).MarkClosing(), Is.True);
AssertNoBinding(created.AuthenticationToken, channel);
await m_manager.CloseSessionAsync(created.SessionId).ConfigureAwait(false);
Assert.That(m_manager.HasSession("one"), Is.False);
}
[Test]
public async Task RestorationCompletingAfterShutdownCannotAdmitSessionAsync()
{
OperationContext channel = CreateContext("restored");
var token = new NodeId("late-restore", 1);
m_manager.RestoreEnabled = true;
m_manager.DelayRestore = true;
Task activation = ActivateTokenAsync(token, channel);
await m_manager.RestoreEntered.Task.ConfigureAwait(false);
try
{
await m_manager.ShutdownAsync().ConfigureAwait(false);
}
finally
{
m_manager.ReleaseRestore.TrySetResult(true);
}
ServiceResultException error = Assert.ThrowsAsync<ServiceResultException>(() => activation)!;
Assert.That(error.StatusCode, Is.EqualTo(StatusCodes.BadServerHalted));
Assert.That(m_manager.GetSession(token), Is.Null);
Assert.That(m_manager.HasSession("restored"), Is.False);
AssertNoBinding(token, channel);
}
private async Task<CreateSessionResult> CreateAsync(OperationContext context)
{
return await m_manager.CreateSessionAsync(
context, m_certificate, "binding", default,
new ApplicationDescription { ApplicationUri = "urn:binding-test" },
context.ChannelContext!.EndpointDescription!.EndpointUrl,
null, [], 60_000, 64 * 1024).ConfigureAwait(false);
}
private Task ActivateAsync(CreateSessionResult created, OperationContext context)
{
return ActivateTokenAsync(created.AuthenticationToken, context);
}
private async Task ActivateTokenAsync(NodeId token, OperationContext context)
{
await m_manager.ActivateSessionAsync(
context, token, null, default, null, []).ConfigureAwait(false);
}
private SessionBindingContext GetBinding(CreateSessionResult created, OperationContext channel)
{
Assert.That(m_manager.TryGetSessionContext(
created.AuthenticationToken, channel.ChannelContext!, out SessionBindingContext? binding), Is.True);
Assert.That(binding, Is.Not.Null);
return binding!;
}
private void AssertNoBinding(NodeId token, OperationContext channel)
{
Assert.That(m_manager.TryGetSessionContext(
token, channel.ChannelContext!, out SessionBindingContext? binding), Is.False);
Assert.That(binding, Is.Null);
}
private OperationContext CreateContext(
string channelId,
MessageSecurityMode mode = MessageSecurityMode.None,
string policy = SecurityPolicies.None,
byte[]? certificate = null,
string endpointUrl = "opc.tcp://localhost:4840/binding")
{
var endpoint = new EndpointDescription
{
EndpointUrl = endpointUrl,
SecurityMode = mode,
SecurityPolicyUri = policy,
UserIdentityTokens =
[
new UserTokenPolicy
{
PolicyId = "anonymous",
TokenType = UserTokenType.Anonymous,
SecurityPolicyUri = SecurityPolicies.None
},
new UserTokenPolicy
{
PolicyId = "username",
TokenType = UserTokenType.UserName,
SecurityPolicyUri = SecurityPolicies.None
}
]
};
var context = new OperationContext(
new RequestHeader(),
new SecureChannelContext(channelId, endpoint, RequestEncoding.Binary, certificate),
RequestType.ActivateSession, RequestLifetime.None);
m_contexts.Add(context);
return context;
}
private readonly List<OperationContext> m_contexts = [];
private Certificate m_certificate = null!;
private CallbackTimeProvider m_clock = null!;
private BindingSessionManager m_manager = null!;
private Mock<IDiagnosticsNodeManager> m_diagnostics = null!;
private sealed class CallbackTimeProvider : TimeProvider
{
public override long TimestampFrequency => m_clock.TimestampFrequency;
public override DateTimeOffset GetUtcNow()
{
return m_clock.GetUtcNow();
}
public override long GetTimestamp()
{
Interlocked.Exchange(ref m_callback, null)?.Invoke();
return m_clock.GetTimestamp();
}
public void Advance(TimeSpan duration)
{
m_clock.Advance(duration);
}
public void OnNextTimestamp(Action callback)
{
m_callback = callback ?? throw new ArgumentNullException(nameof(callback));
}
private readonly FakeTimeProvider m_clock = new();
private Action? m_callback;
}
private sealed class BindingSessionManager : SessionManager
{
public BindingSessionManager(IServerInternal server, Certificate certificate, TimeProvider clock)
: base(server, new ApplicationConfiguration
{
ServerConfiguration = new ServerConfiguration
{
MinSessionTimeout = 1000,
MaxSessionTimeout = 60_000,
MaxSessionCount = 10
}
}, clock)
{
m_server = server;
m_certificate = certificate;
}
public bool RejectAuthentication { get; set; }
public bool RestoreEnabled { get; set; }
public bool DelayRestore { get; set; }
public long LastCompletedCallbackSequence { get; private set; }
public TaskCompletionSource<bool> CallbackEntered { get; } =
new(TaskCreationOptions.RunContinuationsAsynchronously);
public TaskCompletionSource<bool> ReleaseCallback { get; } =
new(TaskCreationOptions.RunContinuationsAsynchronously);
public TaskCompletionSource<bool> AuthenticationEntered { get; } =
new(TaskCreationOptions.RunContinuationsAsynchronously);
public TaskCompletionSource<bool> ReleaseAuthentication { get; } =
new(TaskCreationOptions.RunContinuationsAsynchronously);
public TaskCompletionSource<bool> RestoreEntered { get; } =
new(TaskCreationOptions.RunContinuationsAsynchronously);
public TaskCompletionSource<bool> ReleaseRestore { get; } =
new(TaskCreationOptions.RunContinuationsAsynchronously);
protected override bool SupportsSessionRestore => RestoreEnabled;
public void DelayNextCallback()
{
m_delayCallback = true;
}
public void DelayNextAuthentication()
{
m_delayAuthentication = true;
}
protected override async ValueTask<(
IUserIdentity? Identity, IUserIdentity? EffectiveIdentity, ServiceResult? Error)>
AuthenticateUserIdentityAsync(
ISession session, IUserIdentityTokenHandler newIdentity, UserTokenPolicy? userTokenPolicy,
EndpointDescription endpointDescription, CancellationToken cancellationToken)
{
if (m_delayAuthentication)
{
m_delayAuthentication = false;
AuthenticationEntered.SetResult(true);
await ReleaseAuthentication.Task.ConfigureAwait(false);
}
if (RejectAuthentication)
{
return (null, null, new ServiceResult(StatusCodes.BadUserAccessDenied));
}
var identity = new UserIdentity(newIdentity);
if (GrantImpersonatedOperatorRole)
{
return (identity, new RoleBasedIdentity(identity, [Role.Operator], m_server.NamespaceUris), null);
}
return (identity, identity, null);
}
public bool GrantImpersonatedOperatorRole { get; set; }
public bool ThrowFromMandatoryRoles { get; set; }
public void PublicReevaluateIdentityIfStale(ISession session, SecureChannelContext channelContext)
{
ReevaluateIdentityIfStale(session, channelContext);
}
protected override IUserIdentity AddMandatoryRoles(
ISession session, OperationContext context, IUserIdentity effectiveIdentity)
{
if (ThrowFromMandatoryRoles)
{
throw new InvalidOperationException("Injected role resolution failure.");
}
return base.AddMandatoryRoles(session, context, effectiveIdentity);
}
protected override async ValueTask OnSessionActivatedAsync(
NodeId authenticationToken, ISession session, ByteString serverNonce,
UserTokenType clientUserTokenType, string? clientUserId, long activationSequence,
CancellationToken cancellationToken)
{
if (m_delayCallback)
{
m_delayCallback = false;
CallbackEntered.SetResult(true);
await ReleaseCallback.Task.ConfigureAwait(false);
}
LastCompletedCallbackSequence = activationSequence;
if (CallbackException != null)
{
throw CallbackException;
}
}
public Exception? CallbackException { get; set; }
protected override async ValueTask<ISession?> RestoreSessionAsync(
NodeId authenticationToken, OperationContext context, CancellationToken cancellationToken = default)
{
if (DelayRestore)
{
RestoreEntered.SetResult(true);
await ReleaseRestore.Task.ConfigureAwait(false);
}
ISession session = CreateSession(
context, m_server, m_certificate, authenticationToken, default, Nonce.CreateNonce(32),
"restored", new ApplicationDescription(), context.ChannelContext!.EndpointDescription!.EndpointUrl!,
null!, [], 60_000, 64 * 1024, 0, 0);
await session.InitializeAsync(context, cancellationToken).ConfigureAwait(false);
SetRestoredSessionTransferSecurityState(
session, default, SecurityPolicies.None, MessageSecurityMode.None, UserTokenType.Anonymous, null);
return session;
}
private readonly IServerInternal m_server;
private readonly Certificate m_certificate;
private bool m_delayCallback;
private bool m_delayAuthentication;
}
}
}