using Grpc.Core; using Grpc.Net.Client; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using ZB.MOM.WW.ScadaBridge.Communication; using ZB.MOM.WW.ScadaBridge.Communication.Grpc; namespace ZB.MOM.WW.ScadaBridge.Host.Tests; /// /// End-to-end proof that the two halves of the control-plane PSK actually interoperate: /// on the client and /// on the server, over a real gRPC stack. /// /// /// /// The unit tests either side of this file each test one half against a hand-built input, and /// would both stay green if the halves disagreed — if the credentials never attached to a /// streaming call, if the metadata key case differed, or if attaching call credentials to a /// plaintext channel were rejected outright (gRPC refuses that by default; the code opts in with /// UnsafeUseInsecureChannelCallCredentials, and nothing but a real call proves the opt-in /// works). Getting that wrong takes down every site's streaming and audit-pull path at once, /// which is a bad thing to discover on the rig. /// /// /// Runs entirely in-process over : no ports, no containers. The service /// is a stub rather than the real SiteStreamGrpcServer — this is a test of the auth /// pipeline, and the real server would drag in an actor system for no added coverage. The method /// paths and message types are the real generated ones. /// /// public class ControlPlaneAuthEndToEndTests : IAsyncLifetime { private IHost _host = null!; private TestServer _server = null!; /// Boots the in-process gRPC host with the real interceptor. public async Task InitializeAsync() { _host = await new HostBuilder() .ConfigureWebHost(web => web .UseTestServer() .ConfigureServices(services => { services.AddGrpc(o => o.Interceptors.Add()); services.AddSingleton(Options.Create( new CommunicationOptions { GrpcPsk = SiteKey })); services.AddSingleton(); services.AddSingleton(); }) .Configure(app => { app.UseRouting(); app.UseEndpoints(e => e.MapGrpcService()); })) .StartAsync(); _server = _host.GetTestServer(); } /// public async Task DisposeAsync() { await _host.StopAsync(); _host.Dispose(); } private const string SiteKey = "the-site-a-preshared-key"; /// /// Builds a channel through the test server, credentialed exactly as production does. /// private GrpcChannel Channel(string? key, string siteId = "site-a") { var options = new GrpcChannelOptions { HttpHandler = _server.CreateHandler() }; if (key is not null) { options.WithSiteCredentials(new FixedPskProvider(key), siteId); } return GrpcChannel.ForAddress(_server.BaseAddress, options); } private sealed class FixedPskProvider(string key) : ISitePskProvider { public ValueTask GetAsync(string siteId, CancellationToken ct) => new(key); public void Invalidate(string siteId) { } } /// Stub service: echoes back what the auth pipeline let through. private sealed class EchoSiteStreamService : SiteStreamService.SiteStreamServiceBase { /// The site header the last accepted call carried. public string? LastSiteHeader { get; private set; } public override Task PullAuditEvents( PullAuditEventsRequest request, ServerCallContext context) { LastSiteHeader = context.RequestHeaders .FirstOrDefault(h => h.Key == ControlPlaneCredentials.SiteHeader)?.Value; return Task.FromResult(new PullAuditEventsResponse { MoreAvailable = false }); } public override async Task SubscribeInstance( InstanceStreamRequest request, IServerStreamWriter responseStream, ServerCallContext context) { await responseStream.WriteAsync(new SiteStreamEvent { CorrelationId = request.CorrelationId }); } } [Fact] public async Task CorrectKey_IsAccepted_OnAUnaryCall() { using var channel = Channel(SiteKey); var client = new SiteStreamService.SiteStreamServiceClient(channel); var reply = await client.PullAuditEventsAsync(new PullAuditEventsRequest { BatchSize = 1 }); Assert.False(reply.MoreAvailable); } [Fact] public async Task WrongKey_IsRejected_WithPermissionDenied() { using var channel = Channel("some-other-sites-key"); var client = new SiteStreamService.SiteStreamServiceClient(channel); var ex = await Assert.ThrowsAsync( async () => await client.PullAuditEventsAsync(new PullAuditEventsRequest())); Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode); } [Fact] public async Task NoCredentialsAtAll_IsRejected() { // The pre-T0.3 client shape. This is the case that proves the gap is actually closed. using var channel = Channel(key: null); var client = new SiteStreamService.SiteStreamServiceClient(channel); var ex = await Assert.ThrowsAsync( async () => await client.PullAuditEventsAsync(new PullAuditEventsRequest())); Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode); } [Fact] public async Task CredentialsApplyToStreamingCalls_NotJustUnaryOnes() { // CallCredentials cover every call on the channel; a client interceptor that only // handled the unary path would pass the test above and still break every subscription. using var channel = Channel(SiteKey); var client = new SiteStreamService.SiteStreamServiceClient(channel); using var call = client.SubscribeInstance( new InstanceStreamRequest { CorrelationId = "c1", InstanceUniqueName = "i1" }); Assert.True(await call.ResponseStream.MoveNext(CancellationToken.None)); Assert.Equal("c1", call.ResponseStream.Current.CorrelationId); } [Fact] public async Task WrongKey_IsRejected_OnStreamingCallsToo() { using var channel = Channel("wrong"); var client = new SiteStreamService.SiteStreamServiceClient(channel); using var call = client.SubscribeInstance(new InstanceStreamRequest { CorrelationId = "c1" }); var ex = await Assert.ThrowsAsync( async () => await call.ResponseStream.MoveNext(CancellationToken.None)); Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode); } [Fact] public async Task AnUnresolvableKey_FailsTheCall_RatherThanDialingWithoutOne() { // SitePskProvider throws when a site has no key anywhere. What matters here is that the // throw stops the call: the alternative — swallowing it and sending the request // unauthenticated — is the exact failure this design exists to prevent. The status code // is gRPC's choice, so assert the RpcException and record what it actually is rather // than pinning a guess: callers already treat every non-OK status as a failed call, and // the diagnosable signal is SitePskProvider's own LogError, not this code. var options = new GrpcChannelOptions { HttpHandler = _server.CreateHandler() } .WithSiteCredentials(new ThrowingPskProvider(), "site-a"); using var channel = GrpcChannel.ForAddress(_server.BaseAddress, options); var client = new SiteStreamService.SiteStreamServiceClient(channel); var ex = await Assert.ThrowsAsync( async () => await client.PullAuditEventsAsync(new PullAuditEventsRequest())); Assert.NotEqual(StatusCode.OK, ex.StatusCode); // And nothing reached the service. Assert.Null(_host.Services.GetRequiredService().LastSiteHeader); } private sealed class ThrowingPskProvider : ISitePskProvider { public ValueTask GetAsync(string siteId, CancellationToken ct) => throw new InvalidOperationException($"no key for '{siteId}'"); public void Invalidate(string siteId) { } } [Fact] public async Task TheSiteHeaderTravels_SoCentralCanPickAPerSiteKeyInPhase1A() { // Central's own interceptor (T1A.2) verifies against the key for the site named in this // header. Shipping it now means Phase 1A adds a lookup, not a wire change. using var channel = Channel(SiteKey, siteId: "site-a"); var client = new SiteStreamService.SiteStreamServiceClient(channel); await client.PullAuditEventsAsync(new PullAuditEventsRequest()); var service = _host.Services.GetRequiredService(); Assert.Equal("site-a", service.LastSiteHeader); } }