Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/Configuration/CentralCommandAuthInterceptorTests.cs
T
Joseph Doherty 4d2dfb5b50 test(mesh): step 7 — replace the boundary test with MeshGrpcBoundaryTests and sweep the comments
MeshGrpcBoundaryTests replaces the MeshClusterClientBoundaryTests deleted in step 1. Three
self-seeded single-member clusters sharing the ActorSystem name, joined ONLY by gRPC, driving
the REAL CentralCommunicationActor -> MeshGrpcCommandDialer -> Kestrel h2c ->
MeshCommandAuthInterceptor -> NodeCommandGrpcService -> NodeCommunicationActor -> EventStream:

  1. No_cross_mesh_Akka_association_forms_between_central_and_either_site — the program's own
     point, kept as a test rather than a live-gate note: each cluster stays single-member and
     none ever holds another's address as a member.
  2. One_dispatch_through_central_reaches_a_node_in_every_cluster_mesh — the fan, with each
     site holding its OWN per-cluster PSK, so a dial that selected the wrong cluster's key
     would be refused at the far side.
  3. Falsifiability_control_a_dial_carrying_the_wrong_cluster_key_delivers_nothing — without
     it, (2) cannot distinguish "the gRPC boundary carried it" from "something else in the
     process did". The same node then accepts the right key, so the silence is the auth gate
     and not a dead endpoint.

All three run fully offline — no Docker fixture — and pass.

Comment sweep across every remaining clusterclient|receptionist hit in src/ and tests/:
the program label becomes "mesh gRPC command plane", and every comment that described the
deleted transport as live now describes what actually runs (Dps = pair-local default,
Grpc = cross-mesh). mesh_transport.proto's header/service comments are reworded; no message,
field or enum is touched — the contract is locked. MeshCommActorPathTests is reshaped rather
than retired: the two paths still exist and still matter, but what a mismatch costs is now a
misleading Unavailable from the gRPC ingress rather than a silently dropped receptionist send.

The only surviving mentions of the old names are the ones that must survive: the fail-closed
rules that name "ClusterClient" as a retired value refused at boot, their tests, and this
test class's own account of what it replaces.
2026-08-06 15:58:36 -04:00

332 lines
16 KiB
C#

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;
/// <summary>
/// mesh gRPC command plane, Phase 0 — the central-side ack endpoint's inbound gate. Mapping
/// <c>CentralCommandService</c> onto the pipeline is a Phase 1 task; this test covers only the
/// interceptor's own authorization behavior.
/// </summary>
/// <remarks>
/// Differs from <see cref="MeshCommandAuthInterceptor"/> in the one way central differs from a
/// node: there is no single expected key. The caller names its cluster in
/// <c>x-otopcua-cluster</c>, the bearer is verified against <i>that</i> 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.
/// </remarks>
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<CentralCommandAuthInterceptor>.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);
}
/// <summary>Invokes the interceptor's unary path (how <c>ReportApply</c> actually runs).</summary>
private static Task Invoke(CentralCommandAuthInterceptor interceptor, ServerCallContext context)
=> interceptor.UnaryServerHandler<string, string>(
"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<RpcException>(() => 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<RpcException>(() => 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<RpcException>(() => 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<RpcException>(() => 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<RpcException>(() => 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<RpcException>(() => 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<RpcException>(() => 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<RpcException>(() => 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<RpcException>(() => Invoke(
interceptor, CreateContext(ReportApplyMethod, "Bearer psk-site-a", clusterHeader: null)));
var unknownCluster = await Should.ThrowAsync<RpcException>(() => Invoke(
interceptor, CreateContext(ReportApplyMethod, "Bearer psk-site-a", "SITE-Z")));
var wrongToken = await Should.ThrowAsync<RpcException>(() => Invoke(
interceptor, CreateContext(ReportApplyMethod, "Bearer wrong", "SITE-A")));
var noToken = await Should.ThrowAsync<RpcException>(() => 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<RpcException>(() =>
interceptor.ServerStreamingServerHandler<string, string>(
"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<RpcException>(() =>
interceptor.ClientStreamingServerHandler<string, string>(
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<RpcException>(() =>
interceptor.DuplexStreamingServerHandler<string, string>(
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<CentralCommandAuthInterceptor>.Instance);
await Invoke(interceptor, CreateContext(ReportApplyMethod, "Bearer psk-site-a", "SITE-A"));
provider.MatchesCalls.ShouldBe(1);
provider.TryGetPskCalls.ShouldBe(0);
}
/// <summary>An <see cref="IMeshPskProvider"/> over a fixed key set; unknown clusters fail closed.</summary>
private sealed class FakeMeshPskProvider(params (string Cluster, string Psk)[] keys) : IMeshPskProvider
{
private readonly Dictionary<string, string> _keys =
keys.ToDictionary(k => k.Cluster, k => k.Psk, StringComparer.OrdinalIgnoreCase);
/// <summary>How many times the raw key was asked for — must stay 0 on the verify path.</summary>
public int TryGetPskCalls { get; private set; }
/// <summary>How many times verification went through the provider's comparison.</summary>
public int MatchesCalls { get; private set; }
public bool TryGetPsk(string clusterId, [NotNullWhen(true)] out string? psk)
{
TryGetPskCalls++;
return _keys.TryGetValue(clusterId, out psk);
}
/// <summary>
/// Implemented independently of <see cref="TryGetPsk"/> 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.
/// </summary>
public bool Matches(string clusterId, string presented)
{
MatchesCalls++;
return !string.IsNullOrWhiteSpace(presented)
&& _keys.TryGetValue(clusterId, out var expected)
&& string.Equals(expected, presented, StringComparison.Ordinal);
}
/// <summary>No-op: the gate is the production provider's behaviour, pinned in its own tests.</summary>
public void SetKnownClusters(IReadOnlyCollection<string> clusterIds) { }
public void Invalidate(string clusterId) { }
public void InvalidateAll() { }
}
/// <summary>
/// Minimal <see cref="ServerCallContext"/> carrying just a method name and request headers —
/// the only two things the interceptor reads. Hand-rolled because
/// <c>Grpc.Core.Testing.TestServerCallContext</c> ships only in the retired native package.
/// </summary>
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;
}
}