using Opc.Ua;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests;
///
/// Gitea #816 — does the driver check the server certificate's host name? Against the plain opc-plc fixture, whose
/// certificate names only its container hostname (e.g. DNS:1fc1de3fcb21) while the driver dials the docker
/// host's address, so a host check MUST fail if it runs at all.
/// Before #816 the driver called the SDK's 8-argument DefaultSessionFactory.CreateAsync, which hard-codes
/// checkDomain: false: the mismatch was never noticed. Now certificateValidation.validateServerHostName
/// decides; unset keeps the old behaviour.
/// Each test trusts the server's certificate in a THROWAWAY PKI (the driver's PkiRootOverride) with
/// auto-accept OFF — auto-accept also accepts a host-name mismatch and would hide the check. The certificate is
/// taken from the server's own GetEndpoints answer: what is under test is the host check, not how trust was
/// established.
///
[Collection(OpcPlcCollection.Name)]
[Trait("Category", "Integration")]
[Trait("Simulator", "opc-plc")]
public sealed class ServerHostNameValidationTests(OpcPlcFixture sim) : IDisposable
{
private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), "otopcua-816-" + Guid.NewGuid().ToString("N"));
///
public void Dispose()
{
try { Directory.Delete(_pkiRoot, recursive: true); } catch { /* best-effort temp cleanup */ }
}
private OpcUaClientDriverOptions SecureOptions(bool? validateHostName) => OpcPlcProfile.BuildOptions(sim.EndpointUrl) with
{
SecurityPolicy = OpcUaSecurityPolicy.Basic256Sha256,
SecurityMode = OpcUaSecurityMode.SignAndEncrypt,
AutoAcceptCertificates = false,
CertificateValidation = new OpcUaCertificateValidationOptions { ValidateServerHostName = validateHostName },
};
/// Trust the fixture's server certificate (as advertised on its secure endpoint) in the throwaway PKI.
private async Task TrustServerCertificateAsync(CancellationToken ct)
{
var appConfig = new ApplicationConfiguration(DefaultTelemetry.Create(_ => { }))
{
ApplicationName = "OtOpcUa #816 trust",
ApplicationType = ApplicationType.Client,
SecurityConfiguration = new SecurityConfiguration(),
TransportQuotas = new TransportQuotas(),
ClientConfiguration = new ClientConfiguration(),
DisableHiResClock = true,
};
using var discovery = await DiscoveryClient.CreateAsync(appConfig, new Uri(sim.EndpointUrl), DiagnosticsMasks.None, ct);
var endpoints = await discovery.GetEndpointsAsync(default, ct);
var secure = endpoints.ToList().First(e => e.SecurityPolicyUri == SecurityPolicies.Basic256Sha256);
var der = secure.ServerCertificate.ToArray();
der.ShouldNotBeEmpty("the secure endpoint must carry the server certificate");
var certs = Path.Combine(_pkiRoot, "trusted", "certs");
Directory.CreateDirectory(certs);
await File.WriteAllBytesAsync(Path.Combine(certs, "opc-plc.der"), der, ct);
}
private async Task InitializeAsync(bool? validateHostName, string id, CancellationToken ct)
{
var drv = new OpcUaClientDriver(SecureOptions(validateHostName), driverInstanceId: id) { PkiRootOverride = _pkiRoot };
try
{
await drv.InitializeAsync("{}", ct);
return drv;
}
catch
{
await drv.DisposeAsync();
throw;
}
}
[Theory]
[InlineData(null)] // unset = the pre-#816 behaviour every existing deployment has
[InlineData(false)] // the explicit NAT / IP-addressed opt-out
public async Task Without_host_name_validation_a_trusted_certificate_for_another_host_connects(bool? validate)
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
var ct = TestContext.Current.CancellationToken;
await TrustServerCertificateAsync(ct);
await using var drv = await InitializeAsync(validate, $"opcua-816-off-{validate}", ct);
var snapshots = await drv.ReadAsync([OpcPlcProfile.StepUpRef], ct);
snapshots[0].StatusCode.ShouldBe(0u, "the secured session must read once the server certificate is trusted");
}
///
/// Review of #816 (reproduced live): SDK 2.0.1 also checks the host at channel creation, against the certificate
/// in the GetEndpoints reply — which opc-plc sends on its None endpoint too — before any security-mode test. The
/// driver must not pass checkDomain for a None session, or the flag refuses an unsecured connect.
///
[Fact]
public async Task With_host_name_validation_on_a_None_session_still_connects()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
var ct = TestContext.Current.CancellationToken;
var options = OpcPlcProfile.BuildOptions(sim.EndpointUrl) with
{
AutoAcceptCertificates = false,
CertificateValidation = new OpcUaCertificateValidationOptions { ValidateServerHostName = true },
};
await using var drv = new OpcUaClientDriver(options, driverInstanceId: "opcua-816-none") { PkiRootOverride = _pkiRoot };
await drv.InitializeAsync("{}", ct);
(await drv.ReadAsync([OpcPlcProfile.StepUpRef], ct))[0].StatusCode.ShouldBe(0u);
}
[Fact]
public async Task With_host_name_validation_the_certificate_is_checked_against_the_configured_host()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
var ct = TestContext.Current.CancellationToken;
await TrustServerCertificateAsync(ct);
var ex = await Should.ThrowAsync(() => InitializeAsync(true, "opcua-816-on", ct));
TestContext.Current.TestOutputHelper?.WriteLine($"refused: {ex}");
// The driver's failover aggregate keeps per-endpoint failures as text. The SDK message names the host it
// checked: the CONFIGURED one (the endpoint URL was rewritten to it), not whatever the server advertised.
var configuredHost = new Uri(sim.EndpointUrl).IdnHost;
var text = ex.ToString();
text.ShouldContain($"The domain '{configuredHost}' is not listed in the server certificate");
text.ShouldContain("validateServerHostName=false", Case.Sensitive, "the refusal must name the opt-out");
}
}