using System.Security.Cryptography.X509Certificates;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using ZB.MOM.WW.ScadaBridge.Commons.Types.DataConnections;
using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Adapters;
///
/// Gitea #121: the real against a real, strictly-trusting
/// in-process server. Before the fix every Sign / SignAndEncrypt session failed before
/// activation with ApplicationCertificate … cannot be found (BadConfigurationError)
/// because the client never configured an application certificate. These tests run the actual
/// adapter through both and
/// , with AutoAccept OFF on both sides.
///
public sealed class RealOpcUaSecureConnectTests : IDisposable
{
// Server_ServerStatus_State — present on every server, readable anonymously.
private const string ServerStateNode = "i=2259";
private readonly string _clientPki =
Path.Combine(Path.GetTempPath(), "sb121-client", Guid.NewGuid().ToString("N"));
///
public void Dispose()
{
try { Directory.Delete(_clientPki, recursive: true); } catch { /* best-effort */ }
}
private OpcUaGlobalOptions ClientOptions(string? applicationUri = null, bool autoCreate = true) => new()
{
ApplicationName = "SB121Client",
ApplicationUri = applicationUri ?? "urn:localhost:SB121Client",
OwnCertificateStorePath = Path.Combine(_clientPki, "own"),
TrustedPeerStorePath = Path.Combine(_clientPki, "trusted"),
TrustedIssuerStorePath = Path.Combine(_clientPki, "issuers"),
RejectedCertificateStorePath = Path.Combine(_clientPki, "rejected"),
AutoCreateApplicationCertificate = autoCreate,
};
private string OwnCertsDir => Path.Combine(_clientPki, "own", "certs");
/// Trusts the server on the client side exactly as the site CertStoreActor does.
private void ClientTrusts(X509Certificate2 server)
{
var dir = Path.Combine(_clientPki, "trusted", "certs");
Directory.CreateDirectory(dir);
File.WriteAllBytes(Path.Combine(dir, $"{server.Thumbprint}.der"), server.RawData);
}
private static OpcUaConnectionOptions Secured(string mode) => new(SecurityMode: mode, AutoAcceptUntrustedCerts: false);
private static OpcUaEndpointConfig ProbeConfig(string url, OpcUaSecurityMode mode) => new()
{
EndpointUrl = url,
SecurityMode = mode,
AutoAcceptUntrustedCerts = false,
};
private async Task ProvisionClientAsync(OpcUaGlobalOptions? options = null) =>
await OpcUaApplicationIdentity.EnsureApplicationCertificateAsync(options ?? ClientOptions(), NullLogger.Instance);
/// Mutual trust: connects, reads, reconnects and restarts on ONE persisted identity.
[Theory]
[InlineData("Sign")]
[InlineData("SignAndEncrypt")]
public async Task Connect_WithMutualTrust_ReadsAndReconnectsOnTheSameIdentity(string mode)
{
using var clientCert = await ProvisionClientAsync();
await using var server = await SecureOpcUaTestServer.StartAsync(clientCert);
ClientTrusts(server.Certificate);
var client = new RealOpcUaClient(ClientOptions());
await client.ConnectAsync(server.Url, Secured(mode));
Assert.True(client.IsConnected);
Assert.Equal(0u, (await client.ReadValueAsync(ServerStateNode)).StatusCode);
// Reconnect on the same instance.
await client.DisconnectAsync();
Assert.False(client.IsConnected);
await client.ConnectAsync(server.Url, Secured(mode));
Assert.Equal(0u, (await client.ReadValueAsync(ServerStateNode)).StatusCode);
await client.DisposeAsync();
// A fresh instance (process restart) loads the persisted identity.
await using var restarted = new RealOpcUaClient(ClientOptions());
await restarted.ConnectAsync(server.Url, Secured(mode));
Assert.Equal(0u, (await restarted.ReadValueAsync(ServerStateNode)).StatusCode);
var der = Assert.Single(Directory.GetFiles(OwnCertsDir, "*.der"));
using var onDisk = X509CertificateLoader.LoadCertificate(await File.ReadAllBytesAsync(der));
Assert.Equal(clientCert.Thumbprint, onDisk.Thumbprint);
}
/// Strict server trust: a server the client has not trusted is refused, and quarantined in rejected/.
[Theory]
[InlineData("Sign")]
[InlineData("SignAndEncrypt")]
public async Task Connect_ServerNotTrustedByClient_IsRefused(string mode)
{
using var clientCert = await ProvisionClientAsync();
await using var server = await SecureOpcUaTestServer.StartAsync(clientCert);
await using var client = new RealOpcUaClient(ClientOptions());
var ex = await Assert.ThrowsAsync(() => client.ConnectAsync(server.Url, Secured(mode)));
// A trust refusal — not the pre-#121 BadConfigurationError ("ApplicationCertificate … cannot be found").
Assert.Equal(Opc.Ua.StatusCodes.BadCertificateUntrusted, ex.StatusCode);
Assert.False(client.IsConnected);
}
/// Strict mutual trust: a server that has not trusted the client refuses the channel.
[Theory]
[InlineData("Sign")]
[InlineData("SignAndEncrypt")]
public async Task Connect_ClientNotTrustedByServer_IsRefused(string mode)
{
await using var server = await SecureOpcUaTestServer.StartAsync();
ClientTrusts(server.Certificate);
await using var client = new RealOpcUaClient(ClientOptions());
var ex = await Assert.ThrowsAsync(() => client.ConnectAsync(server.Url, Secured(mode)));
// The server's refusal of an untrusted client channel — proves a client certificate WAS presented.
Assert.Equal(Opc.Ua.StatusCodes.BadSecurityChecksFailed, ex.StatusCode);
Assert.False(client.IsConnected);
}
/// The verify probe presents the same identity: success under mutual trust.
[Theory]
[InlineData(OpcUaSecurityMode.Sign)]
[InlineData(OpcUaSecurityMode.SignAndEncrypt)]
public async Task Verify_WithMutualTrust_Succeeds(OpcUaSecurityMode mode)
{
using var clientCert = await ProvisionClientAsync();
await using var server = await SecureOpcUaTestServer.StartAsync(clientCert);
ClientTrusts(server.Certificate);
var result = await RealOpcUaClient.VerifyEndpointAsync(
ProbeConfig(server.Url, mode), ClientOptions(), NullLogger.Instance, TimeSpan.FromSeconds(30), CancellationToken.None);
Assert.True(result.Success, $"{result.FailureKind}: {result.Error}");
Assert.Single(Directory.GetFiles(OwnCertsDir, "*.der"));
}
/// The probe captures — and never trusts — an untrusted server certificate.
[Theory]
[InlineData(OpcUaSecurityMode.Sign)]
[InlineData(OpcUaSecurityMode.SignAndEncrypt)]
public async Task Verify_ServerNotTrusted_ReturnsUntrustedCertificate(OpcUaSecurityMode mode)
{
using var clientCert = await ProvisionClientAsync();
await using var server = await SecureOpcUaTestServer.StartAsync(clientCert);
var result = await RealOpcUaClient.VerifyEndpointAsync(
ProbeConfig(server.Url, mode), ClientOptions(), NullLogger.Instance, TimeSpan.FromSeconds(30), CancellationToken.None);
Assert.False(result.Success);
Assert.Equal(VerifyFailureKind.UntrustedCertificate, result.FailureKind);
Assert.Equal(server.Certificate.Thumbprint, result.Cert?.Thumbprint);
Assert.False(Directory.Exists(Path.Combine(_clientPki, "trusted", "certs"))
&& Directory.GetFiles(Path.Combine(_clientPki, "trusted", "certs")).Length > 0,
"the probe must never trust the server certificate");
}
/// The probe fails when the server has not trusted the client.
[Theory]
[InlineData(OpcUaSecurityMode.Sign)]
[InlineData(OpcUaSecurityMode.SignAndEncrypt)]
public async Task Verify_ClientNotTrustedByServer_Fails(OpcUaSecurityMode mode)
{
await using var server = await SecureOpcUaTestServer.StartAsync();
ClientTrusts(server.Certificate);
var result = await RealOpcUaClient.VerifyEndpointAsync(
ProbeConfig(server.Url, mode), ClientOptions(), NullLogger.Instance, TimeSpan.FromSeconds(30), CancellationToken.None);
Assert.False(result.Success);
Assert.NotNull(result.FailureKind);
Assert.Contains("80130000", result.Error); // BadSecurityChecksFailed, not BadConfigurationError
}
///
/// The probe is usually what first creates a node's identity, so creation must reach the
/// supplied logger as a Warning carrying the thumbprint servers must trust — once, not on reuse.
///
[Fact]
public async Task Verify_FirstSecuredProbe_LogsCertificateCreationOnce()
{
await using var server = await SecureOpcUaTestServer.StartAsync();
var log = new CapturingLogger();
for (var i = 0; i < 2; i++)
await RealOpcUaClient.VerifyEndpointAsync(
ProbeConfig(server.Url, OpcUaSecurityMode.SignAndEncrypt), ClientOptions(), log,
TimeSpan.FromSeconds(30), CancellationToken.None);
var der = Assert.Single(Directory.GetFiles(OwnCertsDir, "*.der"));
using var cert = X509CertificateLoader.LoadCertificate(await File.ReadAllBytesAsync(der));
var created = Assert.Single(log.Entries, e => e.Level == LogLevel.Warning && e.Message.Contains("CREATED"));
Assert.Contains(cert.Thumbprint, created.Message);
}
private sealed class CapturingLogger : ILogger
{
public List<(LogLevel Level, string Message)> Entries { get; } = new();
public IDisposable? BeginScope(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func formatter)
{
lock (Entries) Entries.Add((logLevel, formatter(state, exception)));
}
}
/// None is unchanged: it connects without reading or creating the own store.
[Fact]
public async Task Connect_None_DoesNotTouchTheOwnStore()
{
await using var server = await SecureOpcUaTestServer.StartAsync();
await using var client = new RealOpcUaClient(ClientOptions());
await client.ConnectAsync(server.Url, new OpcUaConnectionOptions());
Assert.Equal(0u, (await client.ReadValueAsync(ServerStateNode)).StatusCode);
var probe = await RealOpcUaClient.VerifyEndpointAsync(
ProbeConfig(server.Url, OpcUaSecurityMode.None), ClientOptions(), NullLogger.Instance,
TimeSpan.FromSeconds(30), CancellationToken.None);
Assert.True(probe.Success, $"{probe.FailureKind}: {probe.Error}");
Assert.False(Directory.Exists(Path.Combine(_clientPki, "own")));
}
/// A persisted identity is reused, never rotated — including when concurrent connects race on an empty store.
[Fact]
public async Task Provisioning_ReusesThePersistedCertificate_AndCreatesOnlyOneUnderConcurrency()
{
var certs = await Task.WhenAll(Enumerable.Range(0, 8).Select(_ => ProvisionClientAsync()));
try
{
Assert.Single(certs.Select(c => c.Thumbprint).Distinct());
using var again = await ProvisionClientAsync();
Assert.Equal(certs[0].Thumbprint, again.Thumbprint);
Assert.Single(Directory.GetFiles(OwnCertsDir, "*.der"));
Assert.True(Opc.Ua.X509Utils.CompareApplicationUriWithCertificate(again, "urn:localhost:SB121Client", out _),
"the certificate carries the configured ApplicationUri");
}
finally
{
foreach (var c in certs) c.Dispose();
}
}
/// A persisted certificate that no longer matches the ApplicationUri fails closed instead of being replaced.
[Fact]
public async Task Provisioning_ApplicationUriMismatch_FailsWithoutRotating()
{
using var original = await ProvisionClientAsync();
await Assert.ThrowsAsync(
() => ProvisionClientAsync(ClientOptions(applicationUri: "urn:elsewhere:SB121Client")));
var der = Assert.Single(Directory.GetFiles(OwnCertsDir, "*.der"));
using var onDisk = X509CertificateLoader.LoadCertificate(await File.ReadAllBytesAsync(der));
Assert.Equal(original.Thumbprint, onDisk.Thumbprint);
}
/// With auto-create off, a missing certificate fails the secured connect rather than inventing one.
[Fact]
public async Task Provisioning_AutoCreateDisabled_MissingCertificateFails()
{
await Assert.ThrowsAsync(() => ProvisionClientAsync(ClientOptions(autoCreate: false)));
Assert.False(Directory.Exists(OwnCertsDir) && Directory.GetFiles(OwnCertsDir).Length > 0);
}
}