using System.Diagnostics.CodeAnalysis; using Grpc.Core; using Microsoft.Extensions.Logging.Abstractions; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.ControlPlane.Communication; using ZB.MOM.WW.OtOpcUa.Host.Configuration; namespace ZB.MOM.WW.OtOpcUa.Host.Tests.Configuration; /// /// mesh gRPC command plane, Phase 0 — the central-side ack endpoint's inbound gate. Mapping /// CentralCommandService onto the pipeline is a Phase 1 task; this test covers only the /// interceptor's own authorization behavior. /// /// /// Differs from in the one way central differs from a /// node: there is no single expected key. The caller names its cluster in /// x-otopcua-cluster, the bearer is verified against that cluster's PSK, and a /// cluster with no resolvable key is denied — so a caller cannot pick a cluster to be graded /// against, only claim one it must then prove. /// public sealed class CentralCommandAuthInterceptorTests { private const string ReportApplyMethod = "/mesh_transport.v1.CentralCommandService/ReportApply"; private const string OtherMethod = "/deployment_artifact.v1.DeploymentArtifactService/Fetch"; private static CentralCommandAuthInterceptor CreateInterceptor(params (string Cluster, string Psk)[] keys) => new(new FakeMeshPskProvider(keys), NullLogger.Instance); private static ServerCallContext CreateContext( string method, string? authorizationHeader, string? clusterHeader) => CreateContext( method, authorizationHeader is null ? [] : [authorizationHeader], clusterHeader); private static ServerCallContext CreateContext( string method, string[] authorizationHeaders, string? clusterHeader) { var headers = new Metadata(); foreach (var value in authorizationHeaders) headers.Add("authorization", value); if (clusterHeader is not null) headers.Add("x-otopcua-cluster", clusterHeader); return new FakeServerCallContext(method, headers); } /// Invokes the interceptor's unary path (how ReportApply actually runs). private static Task Invoke(CentralCommandAuthInterceptor interceptor, ServerCallContext context) => interceptor.UnaryServerHandler( "request", context, (_, _) => Task.FromResult("ok")); [Fact] public async Task NonCentralCommandMethod_PassesThrough_EvenWithNoKeysAtAll() { // The interceptor shares the AddGrpc pipeline with config-serve (and, on a fused node, sync // and telemetry), so it sees every call. It must be scoped strictly to its own service. var interceptor = CreateInterceptor(); var context = CreateContext(OtherMethod, authorizationHeader: null, clusterHeader: null); await Invoke(interceptor, context); // no throw } [Fact] public async Task ReportApply_WithCorrectBearerForItsCluster_PassesThrough() { var interceptor = CreateInterceptor(("SITE-A", "psk-site-a")); var context = CreateContext(ReportApplyMethod, "Bearer psk-site-a", "SITE-A"); await Invoke(interceptor, context); // no throw } [Fact] public async Task ReportApply_WithNoClusterHeader_IsDenied() { // Without the cluster there is no key to grade against. Falling back to "try them all" would // make one compromised site's key valid for forging every other site's acks. var interceptor = CreateInterceptor(("SITE-A", "psk-site-a")); var context = CreateContext(ReportApplyMethod, "Bearer psk-site-a", clusterHeader: null); var ex = await Should.ThrowAsync(() => Invoke(interceptor, context)); ex.StatusCode.ShouldBe(StatusCode.PermissionDenied); } [Fact] public async Task ReportApply_WithBlankClusterHeader_IsDenied() { var interceptor = CreateInterceptor(("SITE-A", "psk-site-a")); var context = CreateContext(ReportApplyMethod, "Bearer psk-site-a", " "); var ex = await Should.ThrowAsync(() => Invoke(interceptor, context)); ex.StatusCode.ShouldBe(StatusCode.PermissionDenied); } [Fact] public async Task ReportApply_ForAClusterWithNoResolvableKey_IsDenied() { // Fail-closed on the provider's "no key" — an unseeded or unreachable key must cost the ack, // never authentication. var interceptor = CreateInterceptor(("SITE-A", "psk-site-a")); var context = CreateContext(ReportApplyMethod, "Bearer anything-at-all", "SITE-Z"); var ex = await Should.ThrowAsync(() => Invoke(interceptor, context)); ex.StatusCode.ShouldBe(StatusCode.PermissionDenied); } [Fact] public async Task ReportApply_WithAnotherClustersBearer_IsDenied() { // The isolation proof: SITE-B's key does not authenticate a call claiming to be SITE-A. var interceptor = CreateInterceptor(("SITE-A", "psk-site-a"), ("SITE-B", "psk-site-b")); var context = CreateContext(ReportApplyMethod, "Bearer psk-site-b", "SITE-A"); var ex = await Should.ThrowAsync(() => Invoke(interceptor, context)); ex.StatusCode.ShouldBe(StatusCode.PermissionDenied); } [Fact] public async Task ReportApply_WithNoBearerToken_IsDenied() { var interceptor = CreateInterceptor(("SITE-A", "psk-site-a")); var context = CreateContext(ReportApplyMethod, authorizationHeader: null, clusterHeader: "SITE-A"); var ex = await Should.ThrowAsync(() => Invoke(interceptor, context)); ex.StatusCode.ShouldBe(StatusCode.PermissionDenied); } [Fact] public async Task ReportApply_WithMalformedAuthorizationHeader_IsDenied() { // No "Bearer " prefix at all — must be treated the same as a missing header. var interceptor = CreateInterceptor(("SITE-A", "psk-site-a")); var context = CreateContext(ReportApplyMethod, "psk-site-a", "SITE-A"); var ex = await Should.ThrowAsync(() => Invoke(interceptor, context)); ex.StatusCode.ShouldBe(StatusCode.PermissionDenied); } [Fact] public async Task ReportApply_TokenComparison_IsNotAPrefixMatch() { // A prefix/StartsWith comparison would accept a truncated key and make the secret // recoverable one character at a time. var interceptor = CreateInterceptor(("SITE-A", "psk-site-a")); var context = CreateContext(ReportApplyMethod, "Bearer psk-site-", "SITE-A"); var ex = await Should.ThrowAsync(() => Invoke(interceptor, context)); ex.StatusCode.ShouldBe(StatusCode.PermissionDenied); } [Fact] public async Task ReportApply_WithAJunkAuthorizationHeaderBeforeTheRealOne_IsDenied() { // First entry wins, and it must parse. Scanning past a non-Bearer header for a later one // would let a caller smuggle its credential behind a header an intermediary may have // inspected or rewritten, and would make WHICH credential is checked depend on ordering // nothing guarantees. Pinned identically on MeshCommandAuthInterceptor. var interceptor = CreateInterceptor(("SITE-A", "psk-site-a")); var context = CreateContext( ReportApplyMethod, ["junk", "Bearer psk-site-a"], "SITE-A"); var ex = await Should.ThrowAsync(() => Invoke(interceptor, context)); ex.StatusCode.ShouldBe(StatusCode.PermissionDenied); } [Fact] public async Task EveryDenialArm_ReturnsAnIdenticalDetail() { // The anti-enumeration mechanism IS this identity: the caller supplies the cluster id, so a // reply distinguishing "no key for that cluster" from "wrong key" would let an // unauthenticated caller read central's cluster set off the wire. Asserting only StatusCode // would let a well-meaning "more helpful message" edit break it silently. // (Content-level only — the class remark records the timing oracle that survives until the // Phase 1A known-cluster-set gate.) var interceptor = CreateInterceptor(("SITE-A", "psk-site-a")); var noHeader = await Should.ThrowAsync(() => Invoke( interceptor, CreateContext(ReportApplyMethod, "Bearer psk-site-a", clusterHeader: null))); var unknownCluster = await Should.ThrowAsync(() => Invoke( interceptor, CreateContext(ReportApplyMethod, "Bearer psk-site-a", "SITE-Z"))); var wrongToken = await Should.ThrowAsync(() => Invoke( interceptor, CreateContext(ReportApplyMethod, "Bearer wrong", "SITE-A"))); var noToken = await Should.ThrowAsync(() => Invoke( interceptor, CreateContext(ReportApplyMethod, authorizationHeader: null, clusterHeader: "SITE-A"))); unknownCluster.Status.Detail.ShouldBe(noHeader.Status.Detail); wrongToken.Status.Detail.ShouldBe(noHeader.Status.Detail); noToken.Status.Detail.ShouldBe(noHeader.Status.Detail); noHeader.Status.Detail.ShouldNotBeNullOrWhiteSpace(); } [Fact] public async Task ServerStreamingPath_IsAlsoGated() { // ReportApply is unary today, but gating only that path would leave any future streaming // method on the same service open. Gate every handler shape. var interceptor = CreateInterceptor(("SITE-A", "psk-site-a")); var context = CreateContext(ReportApplyMethod, "Bearer wrong", "SITE-A"); var ex = await Should.ThrowAsync(() => interceptor.ServerStreamingServerHandler( "request", responseStream: null!, context, (_, _, _) => Task.CompletedTask)); ex.StatusCode.ShouldBe(StatusCode.PermissionDenied); } [Fact] public async Task ClientStreamingPath_IsAlsoGated() { var interceptor = CreateInterceptor(("SITE-A", "psk-site-a")); var context = CreateContext(ReportApplyMethod, "Bearer wrong", "SITE-A"); var ex = await Should.ThrowAsync(() => interceptor.ClientStreamingServerHandler( requestStream: null!, context, (_, _) => Task.FromResult("ok"))); ex.StatusCode.ShouldBe(StatusCode.PermissionDenied); } [Fact] public async Task DuplexStreamingPath_IsAlsoGated() { var interceptor = CreateInterceptor(("SITE-A", "psk-site-a")); var context = CreateContext(ReportApplyMethod, "Bearer wrong", "SITE-A"); var ex = await Should.ThrowAsync(() => interceptor.DuplexStreamingServerHandler( requestStream: null!, responseStream: null!, context, (_, _, _) => Task.CompletedTask)); ex.StatusCode.ShouldBe(StatusCode.PermissionDenied); } [Fact] public void HasExactlyOnePublicConstructor() { // Grpc.AspNetCore silently stops invoking an interceptor with more than one public ctor — // this pins the shape so a well-meaning overload doesn't quietly disable auth in production. var publicCtors = typeof(CentralCommandAuthInterceptor).GetConstructors(); publicCtors.Length.ShouldBe(1); } [Fact] public async Task Verification_GoesThroughMatches_AndTheInterceptorNeverHandlesTheKeyItself() { // Phase 1A security register item 2. The interceptor must not fetch the raw key and compare // it — that is how a constant-time comparison eventually becomes '=='. Matches owns the // comparison; TryGetPsk survives only for the DIAL side, which has to send the key. // (The production provider's Matches calls TryGetPsk internally; this fake implements the two // independently, so a zero TryGetPsk count here means the INTERCEPTOR never asked for the key.) var provider = new FakeMeshPskProvider(("SITE-A", "psk-site-a")); var interceptor = new CentralCommandAuthInterceptor( provider, NullLogger.Instance); await Invoke(interceptor, CreateContext(ReportApplyMethod, "Bearer psk-site-a", "SITE-A")); provider.MatchesCalls.ShouldBe(1); provider.TryGetPskCalls.ShouldBe(0); } /// An over a fixed key set; unknown clusters fail closed. private sealed class FakeMeshPskProvider(params (string Cluster, string Psk)[] keys) : IMeshPskProvider { private readonly Dictionary _keys = keys.ToDictionary(k => k.Cluster, k => k.Psk, StringComparer.OrdinalIgnoreCase); /// How many times the raw key was asked for — must stay 0 on the verify path. public int TryGetPskCalls { get; private set; } /// How many times verification went through the provider's comparison. public int MatchesCalls { get; private set; } public bool TryGetPsk(string clusterId, [NotNullWhen(true)] out string? psk) { TryGetPskCalls++; return _keys.TryGetValue(clusterId, out psk); } /// /// Implemented independently of so the two calls are separately /// countable — the interface declares this with no default implementation, deliberately. /// Semantics match the production provider's: no key, or a key that is not exactly the /// presented token. /// public bool Matches(string clusterId, string presented) { MatchesCalls++; return !string.IsNullOrWhiteSpace(presented) && _keys.TryGetValue(clusterId, out var expected) && string.Equals(expected, presented, StringComparison.Ordinal); } /// No-op: the gate is the production provider's behaviour, pinned in its own tests. public void SetKnownClusters(IReadOnlyCollection clusterIds) { } public void Invalidate(string clusterId) { } public void InvalidateAll() { } } /// /// Minimal carrying just a method name and request headers — /// the only two things the interceptor reads. Hand-rolled because /// Grpc.Core.Testing.TestServerCallContext ships only in the retired native package. /// 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; } }