Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CentralControlAuthInterceptorTests.cs
T
Joseph Doherty 780bb9c369 feat(grpc): host CentralControlService on the central node (T1A.2)
Central now ALSO listens for the seven site→central control messages over
gRPC, alongside the existing ClusterClient path. Nothing flips to gRPC yet —
sites keep CentralTransport=Akka (T1A.3's job); central simply starts also
accepting.

- CentralControlGrpcService (Communication.Grpc): decodes each RPC onto the
  SAME in-process message the ClusterClient path carries, Asks the existing
  CentralCommunicationActor (zero handler-logic changes), encodes the reply via
  the T1A.1 mapper. Readiness-gated like SiteStreamGrpcServer.SetReady —
  Unavailable until AkkaHostedService hands the actor over. Heartbeat stays
  fire-and-forget (Tell, always-OK, never gated on readiness). Ingest reuses the
  shared SiteStreamGrpcServer.AuditIngestAskTimeout constant. Fault→status
  mapping is retry-aware: Unavailable (never dispatched, safe to cross-node
  retry) vs DeadlineExceeded/Internal (it ran, do not re-send elsewhere).

- CentralControlAuthInterceptor (Host): a SEPARATE interceptor class, not a
  variant constructor on ControlPlaneAuthInterceptor. Central's model is per-site
  (verify the Bearer token against the key for the site in the required
  x-scadabridge-site header, via ISitePskProvider) where a site verifies its one
  own-key — a genuinely different model. Fail-closed on every branch: missing or
  blank header, unresolvable key, and mismatched token all → PermissionDenied,
  never pass-through. One public constructor only (the explicit-prefix ctor is
  internal), pinned by a reflection test — a second public ctor makes
  Grpc.AspNetCore's GetFactory() throw per-call and silently disables the gate.

- Explicit Kestrel h2c listener on new option ScadaBridge:Node:CentralGrpcPort
  (default 8083, symmetric with sites), mirroring the Site branch. Additive to
  central's :5000 HTTP/1 surface, which is untouched — gRPC does NOT go through
  Traefik (HTTP/1 only). Registered by type on AddGrpc; service mapped with
  MapGrpcService. Port range-validated by NodeOptionsValidator.

- Rig: publish the central gRPC port 9013:8083 / 9014:8083 on both central nodes
  so a later task can exercise it.

Tests: CentralControlEndToEndTests (Host.Tests, TestServer + real interceptor +
real service over a stub actor) proves auth positives/negatives are
distinguishable and covers unary + the ingest bridge shapes; the interceptor is
registered BY TYPE, never in DI. CentralControlAuthInterceptorTests pins the
per-site gate + one-public-ctor invariant. CentralControlGrpcServiceTests
(Communication.Tests, TestKit) covers the readiness gate, fire-and-forget
heartbeat, and the DeadlineExceeded-vs-Unavailable status mapping. No active
<Protobuf> item. Communication.Tests (356) + Host.Tests (384) green.
2026-07-22 18:53:59 -04:00

198 lines
8.7 KiB
C#

using Grpc.Core;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
/// <summary>
/// Central's inbound gate for the site→central gRPC control plane (T1A.2). The mirror of
/// <see cref="ControlPlaneAuthInterceptorTests"/>, but central's verification model is inverted:
/// it looks up the key for the site named in the <c>x-scadabridge-site</c> header and checks the
/// bearer token against THAT, rather than against one own-key.
/// </summary>
/// <remarks>
/// Central genuinely needs a distinct verification model, so it is a separate class with its own
/// single public constructor — not a variant ctor on <see cref="ControlPlaneAuthInterceptor"/>,
/// whose two-public-constructor form once silently disabled the gate. The one-public-ctor
/// invariant is pinned below exactly as the sibling pins it.
/// </remarks>
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";
/// <summary>An <see cref="ISitePskProvider"/> backed by a fixed site→key map; throws for unknown sites.</summary>
private sealed class MapPskProvider(IReadOnlyDictionary<string, string> keys) : ISitePskProvider
{
public ValueTask<string> GetAsync(string siteId, CancellationToken ct)
=> keys.TryGetValue(siteId, out var key)
? new ValueTask<string>(key)
: throw new InvalidOperationException($"no key for '{siteId}'");
public void Invalidate(string siteId) { }
}
private static CentralControlAuthInterceptor CreateInterceptor()
=> new(
new MapPskProvider(new Dictionary<string, string> { [SiteA] = SiteAKey, [SiteB] = SiteBKey }),
NullLogger<CentralControlAuthInterceptor>.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<string, List<AuthProperty>>());
protected override ContextPropagationToken CreatePropagationTokenCore(
ContextPropagationOptions? options)
=> throw new NotSupportedException();
protected override Task WriteResponseHeadersAsyncCore(Metadata responseHeaders)
=> Task.CompletedTask;
}
private static Task<string> Invoke(
CentralControlAuthInterceptor interceptor, ServerCallContext context)
=> interceptor.UnaryServerHandler<string, string>(
"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<RpcException>(() => 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<RpcException>(() => 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<RpcException>(() => 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<RpcException>(() => 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<RpcException>(() => 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}/");
}
}