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