using System.Net; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using Grpc.Core; using Grpc.Net.Client; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting.Server; using Microsoft.AspNetCore.Hosting.Server.Features; using Microsoft.AspNetCore.Server.Kestrel.Core; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Options; using ZB.MOM.WW.ScadaBridge.Communication; using ZB.MOM.WW.ScadaBridge.Communication.Grpc; namespace ZB.MOM.WW.ScadaBridge.Host.Tests; /// /// The control-plane PSK over a real TLS listener (ScadaBridge#64): a Kestrel HTTP/2 endpoint with /// a certificate (h2 negotiated by ALPN), the real , and a /// client channel built by exactly as the /// production channel builders do. /// /// /// /// (what ControlPlaneAuthEndToEndTests /// uses) has no TLS, so this binds real Kestrel on a loopback ephemeral port. The client trusts the /// generated certificate by pinning its thumbprint in the handler's validation callback — the test /// stand-in for "the node trusts the issuing CA"; production uses the OS trust store. /// /// /// The first test is the reason the endpoint argument exists: before it, every channel composed its /// PSK over insecure channel credentials, and Grpc.Net.Client refuses that on an https address. /// /// public sealed class ControlPlaneTlsEndToEndTests : IAsyncLifetime { private const string SiteKey = "the-site-a-preshared-key"; private X509Certificate2 _certificate = null!; private IHost _host = null!; private string _httpsAddress = null!; /// Generates a localhost certificate and starts a TLS gRPC host with the real interceptor. /// A task that represents the asynchronous operation. public async Task InitializeAsync() { using (var rsa = RSA.Create(2048)) { var request = new CertificateRequest("CN=localhost", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); var san = new SubjectAlternativeNameBuilder(); san.AddDnsName("localhost"); request.CertificateExtensions.Add(san.Build()); request.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension( new OidCollection { new Oid("1.3.6.1.5.5.7.3.1") }, critical: false)); // serverAuth using var ephemeral = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddDays(1)); // Round-trip through PFX so the private key is usable by SslStream on every platform. _certificate = X509CertificateLoader.LoadPkcs12(ephemeral.Export(X509ContentType.Pfx), password: null); } _host = await new HostBuilder() .ConfigureWebHost(web => web .UseKestrel(kestrel => kestrel.Listen(IPAddress.Loopback, 0, listen => { listen.Protocols = HttpProtocols.Http2; listen.UseHttps(_certificate); })) .ConfigureServices(services => { // By TYPE, interceptor not in DI — exactly as Program.cs registers it. services.AddGrpc(o => o.Interceptors.Add()); services.AddSingleton(Options.Create(new CommunicationOptions { GrpcPsk = SiteKey })); }) .Configure(app => { app.UseRouting(); app.UseEndpoints(e => e.MapGrpcService()); })) .StartAsync(); var bound = _host.Services.GetRequiredService().Features.Get()!.Addresses.Single(); _httpsAddress = $"https://localhost:{new Uri(bound).Port}"; } /// Stops the host and releases the certificate. /// A task that represents the asynchronous operation. public async Task DisposeAsync() { await _host.StopAsync(); _host.Dispose(); _certificate.Dispose(); } private SocketsHttpHandler PinnedHandler() => new() { EnableMultipleHttp2Connections = true, SslOptions = { RemoteCertificateValidationCallback = (_, cert, _, _) => cert is not null && string.Equals( cert.GetCertHashString(), _certificate.Thumbprint, StringComparison.OrdinalIgnoreCase), }, }; private GrpcChannel Channel(string key, string? endpointForCredentials) => GrpcChannel.ForAddress(_httpsAddress, new GrpcChannelOptions { HttpHandler = PinnedHandler() } .WithMessageLimits() .WithSiteCredentials(new FixedPskProvider(key), "site-a", endpointForCredentials)); /// /// Verifies the pre-#64 composition (no endpoint → insecure credentials) cannot even build an /// https channel. This is the defect the endpoint argument fixes; if Grpc.Net.Client ever stops /// refusing it, this test says so. /// [Fact] public void Https_channel_with_insecure_composition_throws_at_construction() { var ex = Assert.Throws(() => Channel(SiteKey, endpointForCredentials: null)); Assert.Contains("insecure channel credentials", ex.Message); } /// Verifies a correctly keyed call succeeds over TLS and carries the site header. /// A task that represents the asynchronous operation. [Fact] public async Task Correct_key_is_accepted_over_tls() { using var channel = Channel(SiteKey, _httpsAddress); var client = new SiteStreamService.SiteStreamServiceClient(channel); var reply = await client.PullAuditEventsAsync(new PullAuditEventsRequest { BatchSize = 1 }); Assert.False(reply.MoreAvailable); } /// /// Verifies TLS does not replace the PSK: a wrong key over a perfectly good TLS channel is still /// refused by the interceptor. /// /// A task that represents the asynchronous operation. [Fact] public async Task Wrong_key_is_still_rejected_over_tls() { using var channel = Channel("some-other-sites-key", _httpsAddress); var client = new SiteStreamService.SiteStreamServiceClient(channel); var ex = await Assert.ThrowsAsync( async () => await client.PullAuditEventsAsync(new PullAuditEventsRequest())); Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode); } /// /// Verifies the client validates the server certificate: a handler that does not trust it fails /// the call rather than sending the PSK to an unverified peer. /// /// A task that represents the asynchronous operation. [Fact] public async Task Untrusted_server_certificate_fails_the_call() { var distrusting = new SocketsHttpHandler { SslOptions = { RemoteCertificateValidationCallback = (_, _, _, _) => false }, }; using var channel = GrpcChannel.ForAddress(_httpsAddress, new GrpcChannelOptions { HttpHandler = distrusting } .WithSiteCredentials(new FixedPskProvider(SiteKey), "site-a", _httpsAddress)); var client = new SiteStreamService.SiteStreamServiceClient(channel); var ex = await Assert.ThrowsAsync( async () => await client.PullAuditEventsAsync(new PullAuditEventsRequest())); // Grpc.Net.Client surfaces a failed handshake as a transport error; what matters is that the // cause is the certificate check, i.e. the call never reached the server. Assert.NotEqual(StatusCode.OK, ex.StatusCode); Assert.Contains(Causes(ex.Status.DebugException), e => e is System.Security.Authentication.AuthenticationException); } private static IEnumerable Causes(Exception? ex) { for (; ex is not null; ex = ex.InnerException) { yield return ex; } } /// /// Verifies the h2c composition is unchanged for an http endpoint: insecure channel credentials /// with the explicit opt-in, as before #64. /// [Fact] public void Http_endpoint_keeps_the_h2c_composition() { var options = new GrpcChannelOptions() .WithSiteCredentials(new FixedPskProvider(SiteKey), "site-a", "http://site-a:8083"); Assert.True(options.UnsafeUseInsecureChannelCallCredentials); // Building the channel on the http address must still succeed. using var channel = GrpcChannel.ForAddress("http://site-a:8083", options); } /// Verifies the https endpoint drops the insecure opt-in. [Fact] public void Https_endpoint_drops_the_insecure_opt_in() { var options = new GrpcChannelOptions() .WithSiteCredentials(new FixedPskProvider(SiteKey), "site-a", "https://site-a:8083"); Assert.False(options.UnsafeUseInsecureChannelCallCredentials); } private sealed class FixedPskProvider(string key) : ISitePskProvider { /// public ValueTask GetAsync(string siteId, CancellationToken ct) => new(key); /// public void Invalidate(string siteId) { } } /// Stub service: the interceptor is under test, not the service. private sealed class EchoSiteStreamService : SiteStreamService.SiteStreamServiceBase { /// public override Task PullAuditEvents( PullAuditEventsRequest request, ServerCallContext context) => Task.FromResult(new PullAuditEventsResponse { MoreAvailable = false }); } }