using System.Net;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using Grpc.Core;
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.AuditLog.Central;
using ZB.MOM.WW.ScadaBridge.Communication;
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
///
/// The three AuditLog pull invokers over a real HTTPS gRPC listener (ScadaBridge#108): real Kestrel
/// HTTP/2 over TLS (ALPN), the real , and the production
/// invokers. Same shape as (ScadaBridge#64). Before the
/// invokers passed their endpoint to WithSiteCredentials they composed the PSK over insecure
/// credentials and threw at channel construction for any https site address.
///
public sealed class AuditLogPullTlsEndToEndTests : IAsyncLifetime
{
private const string SiteKey = "the-site-a-preshared-key";
private X509Certificate2 _certificate = null!;
private IHost _host = null!;
private string _address = 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());
using var ephemeral = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddDays(1));
_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 =>
{
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();
_address = $"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 void Trust(SocketsHttpHandler handler) =>
handler.SslOptions.RemoteCertificateValidationCallback = (_, cert, _, _) =>
cert is not null && string.Equals(cert.GetCertHashString(), _certificate.Thumbprint, StringComparison.OrdinalIgnoreCase);
/// Verifies the audit-events pull succeeds over TLS with the right key.
/// A task that represents the asynchronous operation.
[Fact]
public async Task AuditEvents_pull_works_over_https_with_the_right_key()
{
using var invoker = new GrpcPullAuditEventsInvoker(new CommunicationOptions(), new FixedPskProvider(SiteKey)) { ConfigureHandler = Trust };
var reply = await invoker.InvokeAsync("site-a", _address, new PullAuditEventsRequest { BatchSize = 1 }, CancellationToken.None);
Assert.False(reply.MoreAvailable);
}
/// Verifies the site-calls pull succeeds over TLS with the right key.
/// A task that represents the asynchronous operation.
[Fact]
public async Task SiteCalls_pull_works_over_https_with_the_right_key()
{
using var invoker = new GrpcPullSiteCallsInvoker(new CommunicationOptions(), new FixedPskProvider(SiteKey)) { ConfigureHandler = Trust };
var reply = await invoker.InvokeAsync("site-a", _address, new PullSiteCallsRequest { BatchSize = 1 }, CancellationToken.None);
Assert.False(reply.MoreAvailable);
}
/// Verifies the trace-events pull succeeds over TLS with the right key.
/// A task that represents the asynchronous operation.
[Fact]
public async Task TraceEvents_pull_works_over_https_with_the_right_key()
{
using var invoker = new GrpcPullTraceEventsInvoker(new CommunicationOptions(), new FixedPskProvider(SiteKey)) { ConfigureHandler = Trust };
var reply = await invoker.InvokeAsync("site-a", _address, new PullTraceEventsRequest(), CancellationToken.None);
Assert.NotNull(reply);
}
/// Verifies TLS does not replace the PSK: a wrong key is still PermissionDenied on all three pulls.
/// A task that represents the asynchronous operation.
[Fact]
public async Task Wrong_key_is_still_rejected_over_https()
{
var wrong = new FixedPskProvider("some-other-sites-key");
using var a = new GrpcPullAuditEventsInvoker(new CommunicationOptions(), wrong) { ConfigureHandler = Trust };
using var s = new GrpcPullSiteCallsInvoker(new CommunicationOptions(), wrong) { ConfigureHandler = Trust };
using var t = new GrpcPullTraceEventsInvoker(new CommunicationOptions(), wrong) { ConfigureHandler = Trust };
foreach (var call in new Func[]
{
() => a.InvokeAsync("site-a", _address, new PullAuditEventsRequest(), CancellationToken.None),
() => s.InvokeAsync("site-a", _address, new PullSiteCallsRequest(), CancellationToken.None),
() => t.InvokeAsync("site-a", _address, new PullTraceEventsRequest(), CancellationToken.None),
})
{
var ex = await Assert.ThrowsAsync(call);
Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode);
}
}
/// Verifies an untrusted server certificate fails the call before the PSK is sent.
/// A task that represents the asynchronous operation.
[Fact]
public async Task Untrusted_server_certificate_fails_the_pull()
{
using var invoker = new GrpcPullAuditEventsInvoker(new CommunicationOptions(), new FixedPskProvider(SiteKey))
{
ConfigureHandler = h => h.SslOptions.RemoteCertificateValidationCallback = (_, _, _, _) => false,
};
var ex = await Assert.ThrowsAsync(
() => invoker.InvokeAsync("site-a", _address, new PullAuditEventsRequest(), CancellationToken.None));
Assert.NotEqual(StatusCode.PermissionDenied, ex.StatusCode);
Assert.NotEqual(StatusCode.OK, ex.StatusCode);
}
private sealed class FixedPskProvider(string key) : ISitePskProvider
{
///
public ValueTask GetAsync(string siteId, CancellationToken ct) => new(key);
///
public void Invalidate(string siteId) { }
}
private sealed class StubSiteStreamService : SiteStreamService.SiteStreamServiceBase
{
///
public override Task PullAuditEvents(PullAuditEventsRequest request, ServerCallContext context) =>
Task.FromResult(new PullAuditEventsResponse());
///
public override Task PullSiteCalls(PullSiteCallsRequest request, ServerCallContext context) =>
Task.FromResult(new PullSiteCallsResponse());
///
public override Task PullTraceEvents(PullTraceEventsRequest request, ServerCallContext context) =>
Task.FromResult(new PullTraceEventsResponse());
}
}