Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/HistorianGatewayTlsPolicyWireTests.cs
T
Joseph Doherty 230cef4ef0
v2-ci / unit-tests (push) Has been cancelled
build: adopt HistorianGateway 1.0 TLS options
2026-11-10 14:40:30 -05:00

143 lines
6.3 KiB
C#

using System.Net;
using System.Security.Authentication;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using Grpc.Core;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Server.Kestrel.Core;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using Xunit;
using ZB.MOM.WW.HistorianGateway.Client;
using ZB.MOM.WW.HistorianGateway.Contracts.Grpc;
using ZB.MOM.WW.OtOpcUa.Runtime.Historian;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests;
/// <summary>historiangw#131: verify the mapped TLS posture through an actual local gRPC connection.</summary>
public sealed class HistorianGatewayTlsPolicyWireTests(ITestOutputHelper output)
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
[Fact]
public async Task Default_tls_rejects_an_untrusted_server_before_the_rpc()
{
await using var server = await Server.StartAsync(tls: true);
using var adapter = HistorianGatewayClientAdapter.Create(server.Options(), NullLoggerFactory.Instance);
var error = await Should.ThrowAsync<HistorianGatewayException>(
() => adapter.TagExistsAsync("T", Ct).WaitAsync(TimeSpan.FromSeconds(15), Ct));
output.WriteLine($"TLS rejection: {error}");
ContainsTlsAuthenticationFailure(error).ShouldBeTrue("the failure must be TLS authentication, not an arbitrary connection failure");
server.MetadataCalls.ShouldBe(0);
}
[Fact]
public async Task Explicit_tls_opt_out_reaches_the_same_self_signed_server_shape()
{
await using var server = await Server.StartAsync(tls: true);
var options = server.Options(allowUntrusted: true);
using var adapter = HistorianGatewayClientAdapter.Create(options, NullLoggerFactory.Instance);
(await adapter.TagExistsAsync("T", Ct).WaitAsync(TimeSpan.FromSeconds(15), Ct)).ShouldBeTrue();
server.MetadataCalls.ShouldBe(1);
}
[Fact]
public async Task Plaintext_ignores_stale_tls_opt_out_and_ca_settings_and_reaches_the_rpc()
{
await using var server = await Server.StartAsync(tls: false);
var options = server.Options(allowUntrusted: true, caPath: "/nonexistent/historiangw-131-stale-ca.pem");
using var adapter = HistorianGatewayClientAdapter.Create(options, NullLoggerFactory.Instance);
(await adapter.TagExistsAsync("T", Ct).WaitAsync(TimeSpan.FromSeconds(15), Ct)).ShouldBeTrue();
server.MetadataCalls.ShouldBe(1);
}
private static bool ContainsTlsAuthenticationFailure(Exception error)
{
var pending = new Stack<Exception>();
var seen = new HashSet<Exception>(ReferenceEqualityComparer.Instance);
pending.Push(error);
while (pending.TryPop(out var current))
{
if (!seen.Add(current)) continue;
if (current is AuthenticationException) return true;
if (current.InnerException is { } inner) pending.Push(inner);
if (current is RpcException { Status.DebugException: { } debug }) pending.Push(debug);
}
return false;
}
private sealed class TagsService : HistorianTags.HistorianTagsBase
{
public int Calls;
public override Task<GetTagMetadataReply> GetTagMetadata(GetTagMetadataRequest request, ServerCallContext context)
{
Interlocked.Increment(ref Calls);
return Task.FromResult(new GetTagMetadataReply
{ Metadata = new HistorianTagMetadata { Name = request.Tag } });
}
}
private sealed class Server(WebApplication app, TagsService service, string endpoint,
RSA? key, X509Certificate2? certificate) : IAsyncDisposable
{
public int MetadataCalls => Volatile.Read(ref service.Calls);
public ServerHistorianOptions Options(bool allowUntrusted = false, string? caPath = null) => new()
{
Endpoint = endpoint, ApiKey = "histgw_131_test", UseTls = certificate is not null,
CallTimeout = TimeSpan.FromSeconds(2),
AllowUntrustedServerCertificate = allowUntrusted, CaCertificatePath = caPath,
};
public static async Task<Server> StartAsync(bool tls)
{
RSA? key = null;
X509Certificate2? certificate = null;
if (tls)
{
key = RSA.Create(2048);
var request = new CertificateRequest("CN=localhost", key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
request.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, true));
request.CertificateExtensions.Add(new X509KeyUsageExtension(
X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment, true));
var names = new SubjectAlternativeNameBuilder();
names.AddDnsName("localhost"); names.AddIpAddress(IPAddress.Loopback);
request.CertificateExtensions.Add(names.Build());
certificate = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddDays(1));
}
var service = new TagsService();
var builder = WebApplication.CreateBuilder();
builder.Logging.ClearProviders();
builder.WebHost.ConfigureKestrel(options => options.Listen(IPAddress.Loopback, 0, listen =>
{
listen.Protocols = HttpProtocols.Http2;
if (certificate is not null) listen.UseHttps(certificate);
}));
builder.Services.AddGrpc(); builder.Services.AddSingleton(service);
var app = builder.Build();
app.MapGrpcService<TagsService>();
try
{
await app.StartAsync(Ct);
return new Server(app, service, app.Urls.Single(), key, certificate);
}
catch
{
await app.DisposeAsync(); certificate?.Dispose(); key?.Dispose();
throw;
}
}
public async ValueTask DisposeAsync()
{
try { await app.StopAsync(Ct); }
finally { await app.DisposeAsync(); certificate?.Dispose(); key?.Dispose(); }
}
}
}