diff --git a/src/ZB.MOM.WW.ScadaBridge.Host/ControlPlaneAuthInterceptor.cs b/src/ZB.MOM.WW.ScadaBridge.Host/ControlPlaneAuthInterceptor.cs index ad8c3487..19445447 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Host/ControlPlaneAuthInterceptor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Host/ControlPlaneAuthInterceptor.cs @@ -62,7 +62,19 @@ public sealed class ControlPlaneAuthInterceptor : Interceptor private readonly IOptions _options; private readonly ILogger _logger; - /// Creates the interceptor gating . + /// + /// Creates the interceptor gating . + /// + /// + /// This must remain the ONLY public constructor. AddGrpc registers the + /// interceptor by type, and Grpc.AspNetCore.Server.InterceptorRegistration.GetFactory() + /// throws "Multiple constructors accepting all given argument types have been found" + /// when a second one is applicable. That throw happens per call, inside the pipeline, and + /// surfaces to the caller as Unknown / "Exception was thrown by handler" — so the gate + /// silently stops authorizing anything while still failing every call. A second public + /// constructor added here in a later phase reintroduces exactly that. Pinned by + /// ControlPlaneAuthInterceptorTests.TheInterceptorHasExactlyOnePublicConstructor. + /// /// Communication options; GrpcPsk is the expected bearer token. /// Logger for denial diagnostics. public ControlPlaneAuthInterceptor( @@ -72,11 +84,16 @@ public sealed class ControlPlaneAuthInterceptor : Interceptor { } - /// Creates the interceptor gating an explicit set of service prefixes. + /// + /// Creates the interceptor gating an explicit set of service prefixes. Internal — + /// see the public constructor's remarks for why this cannot be public. Phases that add a + /// service to the gate should extend rather than reach + /// for a second registration shape. + /// /// Communication options; GrpcPsk is the expected bearer token. /// Logger for denial diagnostics. /// Method-path prefixes to gate, e.g. /sitestream.SiteStreamService/. - public ControlPlaneAuthInterceptor( + internal ControlPlaneAuthInterceptor( IOptions options, ILogger logger, IReadOnlyList gatedPrefixes) diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/ControlPlaneAuthEndToEndTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/ControlPlaneAuthEndToEndTests.cs index 577fd0a3..58583775 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/ControlPlaneAuthEndToEndTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/ControlPlaneAuthEndToEndTests.cs @@ -47,10 +47,16 @@ public class ControlPlaneAuthEndToEndTests : IAsyncLifetime .UseTestServer() .ConfigureServices(services => { + // Registered exactly as Program.cs does: by TYPE on AddGrpc, with the + // interceptor itself NOT in DI. That is load-bearing. An earlier version of + // this test added it as a singleton, which let DI hand back the instance and + // bypassed Grpc.AspNetCore's own activation — hiding a defect where the + // interceptor had two public constructors and + // InterceptorRegistration.GetFactory() threw on every single call. The rig + // caught it; this test did not. Do not pre-register it. services.AddGrpc(o => o.Interceptors.Add()); services.AddSingleton(Options.Create( new CommunicationOptions { GrpcPsk = SiteKey })); - services.AddSingleton(); services.AddSingleton(); }) .Configure(app => diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/ControlPlaneAuthInterceptorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/ControlPlaneAuthInterceptorTests.cs index ceee0e79..6845a32c 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/ControlPlaneAuthInterceptorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/ControlPlaneAuthInterceptorTests.cs @@ -205,6 +205,22 @@ public class ControlPlaneAuthInterceptorTests Assert.Equal("ok", await Invoke(interceptor, notGated)); } + [Fact] + public void TheInterceptorHasExactlyOnePublicConstructor() + { + // Grpc.AspNetCore registers this interceptor BY TYPE, and + // InterceptorRegistration.GetFactory() throws "Multiple constructors accepting all given + // argument types have been found" the moment a second public constructor is applicable. + // The throw lands inside the pipeline on every call, so the symptom is not a startup + // failure but a gate that authorizes nothing and fails everything with + // Unknown / "Exception was thrown by handler" — a shape that looks like a handler bug, + // not an auth bug. This shipped once and was caught only on the docker rig; the + // prefix-set constructor is internal now to keep it from recurring. + var publicCtors = typeof(ControlPlaneAuthInterceptor).GetConstructors(); + + Assert.Single(publicCtors); + } + [Fact] public void DefaultGatedPrefixes_MatchTheRealSiteStreamServicePath() {