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; /// historiangw#131: verify the mapped TLS posture through an actual local gRPC connection. 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( () => 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(); var seen = new HashSet(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 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 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(); 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(); } } } }