using Grpc.Core; using Microsoft.Extensions.Logging.Abstractions; using ZB.MOM.WW.ScadaBridge.Communication.Grpc; namespace ZB.MOM.WW.ScadaBridge.Host.Tests; /// /// Central's inbound gate for the site→central gRPC control plane (T1A.2). The mirror of /// , but central's verification model is inverted: /// it looks up the key for the site named in the x-scadabridge-site header and checks the /// bearer token against THAT, rather than against one own-key. /// /// /// Central genuinely needs a distinct verification model, so it is a separate class with its own /// single public constructor — not a variant ctor on , /// whose two-public-constructor form once silently disabled the gate. The one-public-ctor /// invariant is pinned below exactly as the sibling pins it. /// public class CentralControlAuthInterceptorTests { private const string ControlMethod = "/scadabridge.centralcontrol.v1.CentralControlService/SubmitNotification"; private const string SiteStreamMethod = "/sitestream.SiteStreamService/SubscribeInstance"; private const string SiteA = "site-a"; private const string SiteAKey = "site-a-preshared-key"; private const string SiteB = "site-b"; private const string SiteBKey = "site-b-preshared-key"; /// An backed by a fixed site→key map; throws for unknown sites. private sealed class MapPskProvider(IReadOnlyDictionary keys) : ISitePskProvider { public ValueTask GetAsync(string siteId, CancellationToken ct) => keys.TryGetValue(siteId, out var key) ? new ValueTask(key) : throw new InvalidOperationException($"no key for '{siteId}'"); public void Invalidate(string siteId) { } } private static CentralControlAuthInterceptor CreateInterceptor() => new( new MapPskProvider(new Dictionary { [SiteA] = SiteAKey, [SiteB] = SiteBKey }), NullLogger.Instance); private static ServerCallContext CreateContext( string method, string? siteHeader, string? authorizationHeader) { var headers = new Metadata(); if (siteHeader is not null) headers.Add(ControlPlaneCredentials.SiteHeader, siteHeader); if (authorizationHeader is not null) headers.Add(ControlPlaneCredentials.AuthorizationHeader, authorizationHeader); return new FakeServerCallContext(method, headers); } private sealed class FakeServerCallContext(string method, Metadata requestHeaders) : ServerCallContext { protected override string MethodCore => method; protected override string HostCore => "localhost"; protected override string PeerCore => "ipv4:127.0.0.1:12345"; protected override DateTime DeadlineCore => DateTime.UtcNow.AddMinutes(1); protected override Metadata RequestHeadersCore => requestHeaders; protected override CancellationToken CancellationTokenCore => CancellationToken.None; protected override Metadata ResponseTrailersCore { get; } = []; protected override Status StatusCore { get; set; } protected override WriteOptions? WriteOptionsCore { get; set; } protected override AuthContext AuthContextCore { get; } = new(null, new Dictionary>()); protected override ContextPropagationToken CreatePropagationTokenCore( ContextPropagationOptions? options) => throw new NotSupportedException(); protected override Task WriteResponseHeadersAsyncCore(Metadata responseHeaders) => Task.CompletedTask; } private static Task Invoke( CentralControlAuthInterceptor interceptor, ServerCallContext context) => interceptor.UnaryServerHandler( "request", context, (_, _) => Task.FromResult("ok")); [Fact] public async Task NonGatedMethod_PassesThrough() { // Central's interceptor gates only CentralControlService; anything else on the listener // (there is nothing today) is not its concern. var interceptor = CreateInterceptor(); var context = CreateContext(SiteStreamMethod, siteHeader: null, authorizationHeader: null); Assert.Equal("ok", await Invoke(interceptor, context)); } [Fact] public async Task CorrectSiteAndKey_IsAccepted() { var interceptor = CreateInterceptor(); var context = CreateContext(ControlMethod, SiteA, $"Bearer {SiteAKey}"); Assert.Equal("ok", await Invoke(interceptor, context)); } [Fact] public async Task MissingSiteHeader_IsDenied() { // Fail-closed: no header means no per-site key to verify against, so there is nothing to // pass through TO. A present bearer token does not rescue it. var interceptor = CreateInterceptor(); var context = CreateContext(ControlMethod, siteHeader: null, $"Bearer {SiteAKey}"); var ex = await Assert.ThrowsAsync(() => Invoke(interceptor, context)); Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode); } [Fact] public async Task BlankSiteHeader_IsDenied() { var interceptor = CreateInterceptor(); var context = CreateContext(ControlMethod, siteHeader: " ", $"Bearer {SiteAKey}"); var ex = await Assert.ThrowsAsync(() => Invoke(interceptor, context)); Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode); } [Fact] public async Task UnknownSite_IsDenied_NotPassedThrough() { // The provider throws for an unknown site; the interceptor must turn that into a denial, // never swallow it and let the call proceed unauthenticated. var interceptor = CreateInterceptor(); var context = CreateContext(ControlMethod, "site-nonexistent", "Bearer whatever"); var ex = await Assert.ThrowsAsync(() => Invoke(interceptor, context)); Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode); } [Fact] public async Task NoBearerToken_IsDenied() { var interceptor = CreateInterceptor(); var context = CreateContext(ControlMethod, SiteA, authorizationHeader: null); var ex = await Assert.ThrowsAsync(() => Invoke(interceptor, context)); Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode); } [Fact] public async Task WrongKeyForTheSite_IsDenied() { // site-a presents site-b's key. Both keys are valid keys; the point is the token must // match the key for THIS site, not just be a key central knows. var interceptor = CreateInterceptor(); var context = CreateContext(ControlMethod, SiteA, $"Bearer {SiteBKey}"); var ex = await Assert.ThrowsAsync(() => Invoke(interceptor, context)); Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode); } [Fact] public async Task EachSiteIsVerifiedAgainstItsOwnKey() { // site-b with site-b's key is accepted by the same interceptor instance that rejected // site-a-with-site-b's-key above — proving the per-site lookup, not a single shared key. var interceptor = CreateInterceptor(); var context = CreateContext(ControlMethod, SiteB, $"Bearer {SiteBKey}"); Assert.Equal("ok", await Invoke(interceptor, context)); } [Fact] public void TheInterceptorHasExactlyOnePublicConstructor() { // Grpc.AspNetCore registers this interceptor BY TYPE; InterceptorRegistration.GetFactory() // throws "Multiple constructors accepting all given argument types have been found" the // moment a second public constructor is applicable, and the throw lands inside the // pipeline on every call — a gate that authorizes nothing while looking like a handler // bug. The explicit-prefix constructor is internal to keep it from recurring; this pins // that. (Same invariant as ControlPlaneAuthInterceptorTests.) var publicCtors = typeof(CentralControlAuthInterceptor).GetConstructors(); Assert.Single(publicCtors); } [Fact] public void DefaultGatedPrefixes_MatchTheRealCentralControlServicePath() { // A typo here disables the whole gate silently: every call would pass through. Pin it // against the generated service descriptor, not the proto text. var method = CentralControlService.Descriptor.FullName; Assert.Contains( CentralControlAuthInterceptor.DefaultGatedPrefixes, p => p == $"/{method}/"); } }