Files
UA-.NETStandard/tests/Opc.Ua.Core.Tests/Stack/Client/ManagedCertificateLifetimeRegressionTests.cs
romanettandClaude Opus 5.5 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

699 lines
34 KiB
C#

/* ========================================================================
* Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
*
* OPC Foundation MIT License 1.00
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*
* The complete license agreement can be found here:
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
using System;
using System.Collections.Generic;
using System.Security.Cryptography;
using System.Threading;
using System.Threading.Tasks;
using Moq;
using NUnit.Framework;
using Opc.Ua.Bindings;
using Opc.Ua.Security.Certificates;
using Opc.Ua.Tests;
namespace Opc.Ua.Core.Tests.Stack.Client
{
/// <summary>
/// Verifies independent certificate ownership across managed channel open, reuse, rotation, reconnect, and
/// shutdown.
/// </summary>
[TestFixture]
[NonParallelizable]
public sealed class ManagedCertificateLifetimeRegressionTests
{
/// <summary>
/// Verifies channel closure leaves manager-owned certificates and issuer chains usable for later connections.
/// </summary>
[Test]
public async Task ClosingManagedChannelDoesNotDisposeManagerCertificatesAsync(
[Values(false, true)] bool chain,
[Values(false, true)] bool transportOwnsSettings,
[Values(false, true)] bool reverse)
{
long created = Certificate.InstancesCreated;
long disposed = Certificate.InstancesDisposed;
await using (var harness = new ChannelHarness(chain) { TransportOwnsSettings = transportOwnsSettings })
{
IManagedTransportChannel first = await harness.GetAsync("first", reverse).ConfigureAwait(false);
await first.CloseAsync().ConfigureAwait(false);
AssertPrivateKeyWorks(harness.ClientCertificate);
if (chain)
{
Assert.That(harness.ClientChain, Has.Count.EqualTo(2));
using RSA key = harness.ClientChain[1].GetRSAPublicKey()!;
Assert.That(key.KeySize, Is.GreaterThanOrEqualTo(2048));
}
IManagedTransportChannel second = await harness.GetAsync("second", reverse).ConfigureAwait(false);
Assert.That(second.State, Is.EqualTo(ChannelState.Ready));
Assert.That(harness.OpenSettings, Has.Count.EqualTo(2));
await second.CloseAsync().ConfigureAwait(false);
AssertPrivateKeyWorks(harness.ClientCertificate);
}
AssertBalanced(created, disposed);
}
/// <summary>
/// Verifies failed opens release only their own certificate references and allow a subsequent retry.
/// </summary>
[Test]
public async Task FailedOpenReleasesOnlyItsOwnCertificateReferencesAsync(
[Values(false, true)] bool transportOwnsSettings)
{
long created = Certificate.InstancesCreated;
long disposed = Certificate.InstancesDisposed;
await using (var harness = new ChannelHarness(true) { TransportOwnsSettings = transportOwnsSettings })
{
harness.BeforeOpen = (_, _) => throw new ServiceResultException(StatusCodes.BadCommunicationError);
ServiceResultException error = Assert.ThrowsAsync<ServiceResultException>(
() => harness.Manager.GetAsync(Participant("failed")).AsTask())!;
Assert.That(error.StatusCode, Is.EqualTo(StatusCodes.BadCommunicationError));
AssertPrivateKeyWorks(harness.ClientCertificate);
harness.BeforeOpen = null;
IManagedTransportChannel channel = await harness.Manager.GetAsync(Participant("retry")).ConfigureAwait(false);
Assert.That(channel.State, Is.EqualTo(ChannelState.Ready));
Assert.That(harness.OpenSettings, Has.Count.EqualTo(2));
await channel.CloseAsync().ConfigureAwait(false);
}
AssertBalanced(created, disposed);
}
/// <summary>
/// Verifies rotation cannot invalidate a key borrowed by an opening channel, including canceled opens.
/// </summary>
[Test]
public async Task RotationCannotRetireAnOpeningChannelsKeyAsync([Values(false, true)] bool cancel)
{
long created = Certificate.InstancesCreated;
long disposed = Certificate.InstancesDisposed;
using (Certificate replacement = NewCertificate("replacement"))
using (var cancellation = new CancellationTokenSource())
{
await using var harness = new ChannelHarness(true);
var entered = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var release = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
harness.BeforeOpen = async (_, ct) =>
{
entered.TrySetResult(true);
await release.Task.WaitAsync(ct).ConfigureAwait(false);
};
Task<IManagedTransportChannel> opening = harness.Manager.GetAsync(
Participant("opening"), cancellation.Token).AsTask();
try
{
await entered.Task.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false);
harness.Manager.UpdateClientCertificate(replacement, null);
AssertPrivateKeyWorks(harness.OpenSettings[0].ClientCertificate!);
Assert.That(harness.OpenSettings[0].ClientCertificateChain, Has.Count.EqualTo(2));
if (cancel)
{
cancellation.Cancel();
Assert.That(() => opening, Throws.InstanceOf<OperationCanceledException>());
}
else
{
release.TrySetResult(true);
IManagedTransportChannel old = await opening.ConfigureAwait(false);
Assert.That(old.State, Is.EqualTo(ChannelState.Ready));
await old.CloseAsync().ConfigureAwait(false);
}
harness.BeforeOpen = null;
IManagedTransportChannel current = await harness.Manager.GetAsync(Participant("current")).ConfigureAwait(false);
Assert.That(harness.OpenSettings[^1].ClientCertificate!.Thumbprint, Is.EqualTo(replacement.Thumbprint));
AssertPrivateKeyWorks(harness.OpenSettings[^1].ClientCertificate!);
await current.CloseAsync().ConfigureAwait(false);
}
finally
{
release.TrySetResult(true);
try
{
IManagedTransportChannel channel = await opening.ConfigureAwait(false);
await channel.CloseAsync().ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellation.IsCancellationRequested)
{
}
}
}
AssertBalanced(created, disposed);
}
/// <summary>
/// Verifies channel sharing and both reconnect modes release unused snapshots without invalidating manager
/// keys.
/// </summary>
[Test]
public async Task ReuseAndReconnectReleaseUnusedCertificateSnapshotsAsync(
[Values(false, true)] bool inPlace)
{
long created = Certificate.InstancesCreated;
long disposed = Certificate.InstancesDisposed;
await using (var harness = new ChannelHarness(true) { SupportsReconnect = inPlace })
{
IManagedTransportChannel first = await harness.Manager.GetAsync(Participant("first")).ConfigureAwait(false);
IManagedTransportChannel second = await harness.Manager.GetAsync(Participant("second")).ConfigureAwait(false);
Assert.That(harness.OpenSettings, Has.Count.EqualTo(1));
await harness.Manager.ReconnectAsync(first).ConfigureAwait(false);
Assert.That(harness.Reconnects, Is.EqualTo(inPlace ? 1 : 0));
Assert.That(harness.OpenSettings, Has.Count.EqualTo(inPlace ? 1 : 2));
Assert.That(first.State, Is.EqualTo(ChannelState.Ready));
Assert.That(second.State, Is.EqualTo(ChannelState.Ready));
AssertPrivateKeyWorks(harness.ClientCertificate);
await first.CloseAsync().ConfigureAwait(false);
await second.CloseAsync().ConfigureAwait(false);
}
AssertBalanced(created, disposed);
}
/// <summary>
/// Verifies capacity rejection releases its certificate snapshot without opening another transport.
/// </summary>
[Test]
public async Task MaxChannelRejectionReturnsItsCertificateSnapshotAsync()
{
long created = Certificate.InstancesCreated;
long disposed = Certificate.InstancesDisposed;
await using (var harness = new ChannelHarness(true, new ChannelManagerOptions { MaxChannels = 1 }))
{
IManagedTransportChannel first = await harness.Manager.GetAsync(Participant("first")).ConfigureAwait(false);
ServiceResultException error = Assert.ThrowsAsync<ServiceResultException>(
() => harness.Manager.GetAsync(Participant("rejected", "opc.tcp://localhost:4841")).AsTask())!;
Assert.That(error.StatusCode, Is.EqualTo(StatusCodes.BadResourceUnavailable));
Assert.That(harness.OpenSettings, Has.Count.EqualTo(1));
AssertPrivateKeyWorks(harness.ClientCertificate);
await first.CloseAsync().ConfigureAwait(false);
}
AssertBalanced(created, disposed);
}
/// <summary>
/// Verifies recovery of a faulted lease retains the replacement transport's usable private key.
/// </summary>
[Test]
public async Task FaultedLeaseSwapKeepsItsReplacementKeyAliveAsync()
{
long created = Certificate.InstancesCreated;
long disposed = Certificate.InstancesDisposed;
await using (var harness = new ChannelHarness(true))
{
IManagedTransportChannel channel = await harness.Manager.GetAsync(Participant("first")).ConfigureAwait(false);
ServiceResultException error = Assert.ThrowsAsync<ServiceResultException>(
() => harness.Manager.ReconnectAsync(channel, new RetryBudget(TimeSpan.Zero)).AsTask())!;
Assert.That(error.StatusCode, Is.EqualTo(StatusCodes.BadSecureChannelClosed));
Assert.That(channel.State, Is.EqualTo(ChannelState.Faulted));
await harness.Manager.ReconnectAsync(channel).ConfigureAwait(false);
Assert.That(channel.State, Is.EqualTo(ChannelState.Ready));
Assert.That(harness.OpenSettings, Has.Count.GreaterThan(1));
AssertPrivateKeyWorks(harness.OpenSettings[^1].ClientCertificate!);
AssertPrivateKeyWorks(harness.ClientCertificate);
await channel.CloseAsync().ConfigureAwait(false);
}
AssertBalanced(created, disposed);
}
/// <summary>
/// Verifies manager disposal cannot release the certificate still borrowed by an incomplete open operation.
/// </summary>
[Test]
public async Task ManagerDisposalRetainsAnInFlightOpenBorrowUntilItUnwindsAsync()
{
long created = Certificate.InstancesCreated;
long disposed = Certificate.InstancesDisposed;
await using (var harness = new ChannelHarness(true))
{
var entered = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var release = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
harness.BeforeOpen = async (_, _) =>
{
entered.TrySetResult(true);
await release.Task.ConfigureAwait(false);
};
Task<IManagedTransportChannel> opening = harness.Manager.GetAsync(Participant("opening")).AsTask();
try
{
await entered.Task.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false);
await harness.Manager.DisposeAsync().ConfigureAwait(false);
AssertPrivateKeyWorks(harness.OpenSettings[0].ClientCertificate!);
}
finally
{
release.TrySetResult(true);
ServiceResultException error = Assert.ThrowsAsync<ServiceResultException>(() => opening)!;
Assert.That(error.StatusCode, Is.EqualTo(StatusCodes.BadSecureChannelClosed));
}
}
AssertBalanced(created, disposed);
}
/// <summary>
/// Verifies a stopped manager rejects certificate publication without consuming the caller's certificate.
/// </summary>
[Test]
public async Task StoppedManagerRejectsCertificatePublicationWithoutTakingOwnershipAsync()
{
long created = Certificate.InstancesCreated;
long disposed = Certificate.InstancesDisposed;
using (Certificate replacement = NewCertificate("replacement"))
{
await using var harness = new ChannelHarness(true);
await harness.Manager.DisposeAsync().ConfigureAwait(false);
Assert.Throws<ObjectDisposedException>(() => harness.Manager.UpdateClientCertificate(replacement, null));
AssertPrivateKeyWorks(replacement);
}
AssertBalanced(created, disposed);
}
/// <summary>
/// Verifies an acquired transport snapshot retains its installed key after publication changes and shutdown.
/// </summary>
[Test]
public async Task InstalledTransportSnapshotKeepsItsKeyAfterNewPublicationAndShutdownAsync()
{
long created = Certificate.InstancesCreated;
long disposed = Certificate.InstancesDisposed;
ClientChannelCertificateSnapshot? snapshot = null;
try
{
using (Certificate replacement = NewCertificate("next-publication"))
{
await using var harness = new ChannelHarness(true);
var lease = (ManagedTransportChannelLease)await harness.Manager.GetAsync(
Participant("snapshot")).ConfigureAwait(false);
string installedThumbprint = harness.ClientCertificate.Thumbprint;
harness.Manager.UpdateClientCertificate(replacement.AddRef(), null);
snapshot = lease.Entry.SnapshotClientCertificate();
Assert.Multiple(() =>
{
Assert.That(snapshot.Certificate!.Thumbprint, Is.EqualTo(installedThumbprint));
Assert.That(snapshot.Certificate.Thumbprint, Is.Not.EqualTo(replacement.Thumbprint));
Assert.That(snapshot.Chain, Has.Count.EqualTo(2));
});
await lease.CloseAsync().ConfigureAwait(false);
ServiceResultException error = Assert.Throws<ServiceResultException>(
() => lease.Entry.SnapshotClientCertificate())!;
Assert.That(error.StatusCode, Is.EqualTo(StatusCodes.BadSecureChannelClosed));
}
AssertPrivateKeyWorks(snapshot.Certificate!);
}
finally
{
snapshot?.Dispose();
}
AssertBalanced(created, disposed);
}
/// <summary>
/// Verifies reconnect adopts a certificate published during transport open or in-place reconnect.
/// </summary>
[Test]
public async Task ReconnectObservesCertificatePublishedDuringTransportOperationAsync(
[Values(false, true)] bool inPlace,
[Values(1, 2)] int maxAttempts)
{
long created = Certificate.InstancesCreated;
long disposed = Certificate.InstancesDisposed;
using (Certificate replacement = NewCertificate("during-reconnect"))
{
await using var harness = new ChannelHarness(false, maxAttempts: maxAttempts)
{
SupportsReconnect = inPlace
};
var lease = (ManagedTransportChannelLease)await harness.Manager.GetAsync(
Participant("reconnecting")).ConfigureAwait(false);
bool published = false;
void PublishReplacement()
{
if (!published)
{
published = true;
harness.Manager.UpdateClientCertificate(replacement.AddRef(), null);
}
}
harness.BeforeOpen = (_, _) =>
{
PublishReplacement();
return Task.CompletedTask;
};
harness.BeforeReconnect = () =>
{
PublishReplacement();
return default;
};
await harness.Manager.ReconnectAsync(lease).ConfigureAwait(false);
using ClientChannelCertificateSnapshot snapshot = lease.Entry.SnapshotClientCertificate();
Assert.Multiple(() =>
{
Assert.That(published, Is.True);
Assert.That(lease.State, Is.EqualTo(ChannelState.Ready));
Assert.That(snapshot.Certificate!.Thumbprint, Is.EqualTo(replacement.Thumbprint));
Assert.That(harness.OpenSettings[^1].ClientCertificate!.Thumbprint,
Is.EqualTo(replacement.Thumbprint));
});
AssertPrivateKeyWorks(snapshot.Certificate!);
await lease.CloseAsync().ConfigureAwait(false);
}
AssertBalanced(created, disposed);
}
/// <summary>
/// Verifies republishing identical certificate material does not unnecessarily replace the installed transport.
/// </summary>
[Test]
public async Task RepeatedCertificatePublicationKeepsTheInstalledTransportAsync(
[Values(false, true)] bool includeLeafOnlyChain)
{
long created = Certificate.InstancesCreated;
long disposed = Certificate.InstancesDisposed;
await using (var harness = new ChannelHarness(false) { SupportsReconnect = true })
{
IManagedTransportChannel lease = await harness.Manager.GetAsync(Participant("same-key"))
.ConfigureAwait(false);
harness.Manager.UpdateClientCertificate(
harness.ClientCertificate.AddRef(),
includeLeafOnlyChain ? new CertificateCollection { harness.ClientCertificate } : null);
await harness.Manager.ReconnectAsync(lease).ConfigureAwait(false);
Assert.Multiple(() =>
{
Assert.That(lease.State, Is.EqualTo(ChannelState.Ready));
Assert.That(harness.OpenSettings, Has.Count.EqualTo(1));
Assert.That(harness.Reconnects, Is.EqualTo(1));
});
await lease.CloseAsync().ConfigureAwait(false);
}
AssertBalanced(created, disposed);
}
/// <summary>
/// Verifies a certificate change during participant reactivation is observed before reconnect reports
/// readiness.
/// </summary>
[Test]
public async Task ReconnectObservesCertificatePublishedDuringParticipantReactivationAsync(
[Values(1, 2)] int maxAttempts)
{
long created = Certificate.InstancesCreated;
long disposed = Certificate.InstancesDisposed;
using (Certificate replacement = NewCertificate("during-reactivation"))
{
await using var harness = new ChannelHarness(false, maxAttempts: maxAttempts)
{
SupportsReconnect = true
};
Mock<IReconnectParticipant> participant = Mock.Get(Participant("rotating-participant"));
int reactivations = 0;
participant.Setup(value => value.OnReconnectAsync(
It.IsAny<IManagedTransportChannel>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
.Returns(() =>
{
if (++reactivations == 1)
{
harness.Manager.UpdateClientCertificate(replacement.AddRef(), null);
}
return new ValueTask<ParticipantReconnectResult>(ParticipantReconnectResult.Reactivated);
});
var lease = (ManagedTransportChannelLease)await harness.Manager.GetAsync(participant.Object)
.ConfigureAwait(false);
await harness.Manager.ReconnectAsync(lease).ConfigureAwait(false);
using ClientChannelCertificateSnapshot snapshot = lease.Entry.SnapshotClientCertificate();
Assert.Multiple(() =>
{
Assert.That(reactivations, Is.EqualTo(2));
Assert.That(lease.State, Is.EqualTo(ChannelState.Ready));
Assert.That(snapshot.Certificate!.Thumbprint, Is.EqualTo(replacement.Thumbprint));
});
await lease.CloseAsync().ConfigureAwait(false);
}
AssertBalanced(created, disposed);
}
/// <summary>
/// Verifies a changed issuer chain replaces the transport even when the leaf certificate is unchanged.
/// </summary>
[Test]
public async Task ChangedIssuerChainStillReplacesTheInstalledTransportAsync()
{
long created = Certificate.InstancesCreated;
long disposed = Certificate.InstancesDisposed;
using (Certificate issuer = NewCertificate("new-issuer"))
{
await using var harness = new ChannelHarness(false) { SupportsReconnect = true };
IManagedTransportChannel lease = await harness.Manager.GetAsync(Participant("new-chain"))
.ConfigureAwait(false);
harness.Manager.UpdateClientCertificate(
harness.ClientCertificate.AddRef(),
new CertificateCollection { harness.ClientCertificate, issuer });
await harness.Manager.ReconnectAsync(lease).ConfigureAwait(false);
Assert.Multiple(() =>
{
Assert.That(lease.State, Is.EqualTo(ChannelState.Ready));
Assert.That(harness.OpenSettings, Has.Count.EqualTo(2));
Assert.That(harness.OpenSettings[^1].ClientCertificateChain, Has.Count.EqualTo(2));
Assert.That(harness.OpenSettings[^1].ClientCertificateChain![1].Thumbprint,
Is.EqualTo(issuer.Thumbprint));
});
await lease.CloseAsync().ConfigureAwait(false);
}
AssertBalanced(created, disposed);
}
/// <summary>
/// Requires every certificate handle created since the test baseline to have been disposed.
/// </summary>
private static void AssertBalanced(long created, long disposed)
{
Assert.That(Certificate.InstancesDisposed - disposed, Is.EqualTo(Certificate.InstancesCreated - created));
}
/// <summary>
/// Signs and verifies a SHA-256 digest to prove the retained RSA private key remains usable.
/// </summary>
private static void AssertPrivateKeyWorks(Certificate certificate)
{
using RSA key = certificate.GetRSAPrivateKey() ?? throw new InvalidOperationException("No private key.");
using RSA verifier = certificate.GetRSAPublicKey() ?? throw new InvalidOperationException("No public key.");
byte[] hash = new byte[32];
byte[] signature = key.SignHash(hash, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
Assert.That(verifier.VerifyHash(hash, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1), Is.True);
}
/// <summary>
/// Creates an independently owned RSA certificate with a scenario-specific subject.
/// </summary>
private static Certificate NewCertificate(string name)
{
return DefaultCertificateFactory.Instance.CreateCertificate($"CN=managed-{name}").CreateForRSA();
}
/// <summary>
/// Creates a participant that reports successful reactivation against the selected endpoint.
/// </summary>
private static IReconnectParticipant Participant(string id, string url = "opc.tcp://localhost:4840")
{
var participant = new Mock<IReconnectParticipant>();
participant.SetupGet(value => value.Id).Returns(id);
participant.SetupGet(value => value.Endpoint).Returns(new ConfiguredEndpoint(null, new EndpointDescription
{
EndpointUrl = url,
SecurityMode = MessageSecurityMode.None,
SecurityPolicyUri = SecurityPolicies.None,
TransportProfileUri = Profiles.UaTcpTransport
}, new EndpointConfiguration())
{ UpdateBeforeConnect = false });
participant.Setup(value => value.OnReconnectAsync(
It.IsAny<IManagedTransportChannel>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
.Returns(new ValueTask<ParticipantReconnectResult>(ParticipantReconnectResult.Reactivated));
return participant.Object;
}
/// <summary>
/// Owns a managed channel manager and fake transports with controlled certificate adoption and reconnect
/// timing.
/// </summary>
private sealed class ChannelHarness : IAsyncDisposable
{
/// <summary>
/// Creates the initial certificate material and a bounded, zero-delay reconnect policy.
/// </summary>
public ChannelHarness(bool chain, ChannelManagerOptions? options = null, int maxAttempts = 2)
{
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
ClientCertificate = NewCertificate("client");
if (chain)
{
using Certificate issuer = NewCertificate("issuer");
ClientChain = [ClientCertificate, issuer];
}
var bindings = new Mock<ITransportChannelBindings>();
bindings.Setup(value => value.Create(It.IsAny<string>(), It.IsAny<ITelemetryContext>()))
.Returns(CreateChannel);
Manager = new ClientChannelManager(
new ApplicationConfiguration(telemetry), telemetry, bindings.Object,
new ExponentialBackoffChannelReconnectPolicy
{
MinDelay = TimeSpan.Zero,
MaxDelay = TimeSpan.Zero,
MaxAttempts = maxAttempts
},
options: options);
Manager.UpdateClientCertificate(ClientCertificate, ClientChain);
}
/// <summary>
/// Gets the manager coordinating certificate publication and channel leases.
/// </summary>
public ClientChannelManager Manager { get; }
/// <summary>
/// Gets the original application certificate retained by the harness.
/// </summary>
public Certificate ClientCertificate { get; }
/// <summary>
/// Gets the optional original leaf-and-issuer chain.
/// </summary>
public CertificateCollection? ClientChain { get; }
/// <summary>
/// Gets settings observed by every physical transport open attempt.
/// </summary>
public List<TransportChannelSettings> OpenSettings { get; } = [];
/// <summary>
/// Gets or sets a callback that delays or fails transport opening after settings are received.
/// </summary>
public Func<TransportChannelSettings, CancellationToken, Task>? BeforeOpen { get; set; }
/// <summary>
/// Gets or sets work performed during an in-place reconnect.
/// </summary>
public Func<ValueTask>? BeforeReconnect { get; set; }
/// <summary>
/// Gets or sets whether a transport adopts and disposes the certificate handles in its settings.
/// </summary>
public bool TransportOwnsSettings { get; set; } = true;
/// <summary>
/// Gets or sets whether the fake transport advertises in-place reconnect.
/// </summary>
public bool SupportsReconnect { get; set; }
/// <summary>
/// Gets the number of in-place reconnect calls.
/// </summary>
public int Reconnects { get; private set; }
/// <summary>
/// Acquires a managed channel through forward or reverse connection establishment.
/// </summary>
public ValueTask<IManagedTransportChannel> GetAsync(string id, bool reverse)
{
IReconnectParticipant participant = Participant(id);
return reverse
? Manager.GetAsync(
participant.Endpoint, _ => participant, new Mock<ITransportWaitingConnection>().Object)
: Manager.GetAsync(participant);
}
/// <summary>
/// Disposes the manager before releasing the harness's own certificate and chain references.
/// </summary>
public async ValueTask DisposeAsync()
{
await Manager.DisposeAsync().ConfigureAwait(false);
ClientCertificate.Dispose();
ClientChain?.Dispose();
}
/// <summary>
/// Creates a fake transport that records opens and independently disposes any settings it adopts.
/// </summary>
private ITransportChannel CreateChannel()
{
TransportChannelSettings? adopted = null;
var channel = new Mock<ITransportChannel>();
Mock<ISecureChannel> secure = channel.As<ISecureChannel>();
secure.Setup(value => value.OpenAsync(
It.IsAny<Uri>(), It.IsAny<TransportChannelSettings>(), It.IsAny<CancellationToken>()))
.Returns((Uri _, TransportChannelSettings settings, CancellationToken ct) =>
new ValueTask(OpenAsync(settings, ct)));
secure.Setup(value => value.OpenAsync(
It.IsAny<ITransportWaitingConnection>(),
It.IsAny<TransportChannelSettings>(),
It.IsAny<CancellationToken>()))
.Returns((ITransportWaitingConnection _, TransportChannelSettings settings, CancellationToken ct) =>
new ValueTask(OpenAsync(settings, ct)));
channel.SetupGet(value => value.SupportedFeatures)
.Returns(() => SupportsReconnect ? TransportChannelFeatures.Reconnect : TransportChannelFeatures.None);
channel.Setup(value => value.ReconnectAsync(
It.IsAny<ITransportWaitingConnection>(), It.IsAny<CancellationToken>()))
.Returns(() =>
{
Reconnects++;
return BeforeReconnect?.Invoke() ?? default;
});
channel.Setup(value => value.CloseAsync(It.IsAny<CancellationToken>())).Returns(default(ValueTask));
channel.Setup(value => value.Dispose()).Callback(() =>
{
TransportChannelSettings? owned = Interlocked.Exchange(ref adopted, null);
owned?.ServerCertificate?.Dispose();
owned?.ClientCertificate?.Dispose();
owned?.ClientCertificateChain?.Dispose();
});
return channel.Object;
async Task OpenAsync(TransportChannelSettings settings, CancellationToken ct)
{
OpenSettings.Add(settings);
if (TransportOwnsSettings)
{
adopted = settings;
}
if (BeforeOpen != null)
{
await BeforeOpen(settings, ct).ConfigureAwait(false);
}
AssertPrivateKeyWorks(settings.ClientCertificate!);
}
}
}
}
}