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 });
}
}