Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/Configuration/MeshCommandAuthInterceptorTests.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

245 lines
11 KiB
C#

using Grpc.Core;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Cluster;
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
namespace ZB.MOM.WW.OtOpcUa.Host.Tests.Configuration;
/// <summary>
/// mesh gRPC command plane, Phase 0 — the node-side command endpoint's inbound gate. Mapping
/// <c>NodeCommandService</c> onto the pipeline is a Phase 1 task; this test covers only the
/// interceptor's own authorization behavior.
/// </summary>
/// <remarks>
/// Same contract as <see cref="TelemetryStreamAuthInterceptor"/>, scoped to
/// <c>/mesh_transport.v1.NodeCommandService/</c> — fail-closed on an unconfigured key,
/// constant-time comparison, gated on every handler shape so scoping holds regardless of RPC
/// shape. Deliberately NO <c>x-otopcua-cluster</c> requirement: that header is a central-side
/// routing hint, and requiring it here would gate on a value the caller chooses.
/// </remarks>
public sealed class MeshCommandAuthInterceptorTests
{
private const string ExecuteMethod = "/mesh_transport.v1.NodeCommandService/Execute";
private const string OtherMethod = "/telemetry.v1.TelemetryStreamService/Subscribe";
private static MeshCommandAuthInterceptor CreateInterceptor(string? psk)
=> new(
Options.Create(new MeshTransportOptions { GrpcPsk = psk }),
NullLogger<MeshCommandAuthInterceptor>.Instance);
private static ServerCallContext CreateContext(string method, string? authorizationHeader)
=> CreateContext(method, authorizationHeader is null ? [] : [authorizationHeader]);
private static ServerCallContext CreateContext(string method, string[] authorizationHeaders)
{
var headers = new Metadata();
foreach (var value in authorizationHeaders)
headers.Add("authorization", value);
return new FakeServerCallContext(method, headers);
}
/// <summary>Invokes the interceptor's unary path (how <c>Execute</c> actually runs).</summary>
private static Task Invoke(MeshCommandAuthInterceptor interceptor, ServerCallContext context)
=> interceptor.UnaryServerHandler<string, string>(
"request", context, (_, _) => Task.FromResult("ok"));
[Fact]
public async Task NonMeshCommandMethod_PassesThrough_EvenWithNoPskConfigured()
{
// The interceptor shares the AddGrpc pipeline with the sync / config-serve / telemetry
// services, so it sees every call. It must be scoped strictly to the node command service.
var interceptor = CreateInterceptor(psk: null);
var context = CreateContext(OtherMethod, authorizationHeader: null);
await Invoke(interceptor, context); // no throw
}
[Fact]
public async Task ExecuteMethod_WithNoPskConfigured_IsDenied_EvenWithABearerToken()
{
// Fail-closed. "No PSK configured" is the DEFAULT — treating it as "no auth required" would
// let anything that can reach the port drive RestartDriver / AlarmCommand / deployment
// dispatch on this node. A token must not help.
var interceptor = CreateInterceptor(psk: null);
var context = CreateContext(ExecuteMethod, "Bearer anything-at-all");
var ex = await Should.ThrowAsync<RpcException>(() => Invoke(interceptor, context));
ex.StatusCode.ShouldBe(StatusCode.PermissionDenied);
}
[Fact]
public async Task ExecuteMethod_WithBlankPskConfigured_IsDenied()
{
// Blank is distinct from unset in the options (GrpcPsk is nullable on purpose) but must be
// treated identically here: an empty expected token would be satisfied by an empty bearer.
var interceptor = CreateInterceptor(" ");
var context = CreateContext(ExecuteMethod, "Bearer ");
var ex = await Should.ThrowAsync<RpcException>(() => Invoke(interceptor, context));
ex.StatusCode.ShouldBe(StatusCode.PermissionDenied);
}
[Fact]
public async Task ExecuteMethod_WithNoBearerToken_IsDenied()
{
var interceptor = CreateInterceptor("the-cluster-psk");
var context = CreateContext(ExecuteMethod, authorizationHeader: null);
var ex = await Should.ThrowAsync<RpcException>(() => Invoke(interceptor, context));
ex.StatusCode.ShouldBe(StatusCode.PermissionDenied);
}
[Fact]
public async Task ExecuteMethod_WithMalformedAuthorizationHeader_IsDenied()
{
// No "Bearer " prefix at all — must be treated the same as a missing header.
var interceptor = CreateInterceptor("the-cluster-psk");
var context = CreateContext(ExecuteMethod, "the-cluster-psk");
var ex = await Should.ThrowAsync<RpcException>(() => Invoke(interceptor, context));
ex.StatusCode.ShouldBe(StatusCode.PermissionDenied);
}
[Fact]
public async Task ExecuteMethod_WithWrongBearerToken_IsDenied()
{
// This is also the isolation proof: a node holding SITE-A's PSK rejects a caller presenting
// SITE-B's. Nothing about the cluster id is consulted — only whether the key matches.
var interceptor = CreateInterceptor("dev-mesh-psk-SITE-A");
var context = CreateContext(ExecuteMethod, "Bearer dev-mesh-psk-SITE-B");
var ex = await Should.ThrowAsync<RpcException>(() => Invoke(interceptor, context));
ex.StatusCode.ShouldBe(StatusCode.PermissionDenied);
}
[Fact]
public async Task ExecuteMethod_WithCorrectBearerToken_PassesThrough()
{
var interceptor = CreateInterceptor("the-cluster-psk");
var context = CreateContext(ExecuteMethod, "Bearer the-cluster-psk");
await Invoke(interceptor, context); // no throw
}
[Fact]
public async Task ExecuteMethod_WithCorrectBearerToken_AndNoClusterHeader_PassesThrough()
{
// x-otopcua-cluster is a central-side routing hint. Requiring it here would gate on a value
// the caller supplies about itself, which proves nothing the PSK has not already proven.
var interceptor = CreateInterceptor("the-cluster-psk");
var context = CreateContext(ExecuteMethod, "Bearer the-cluster-psk");
await Invoke(interceptor, context); // no throw
}
[Fact]
public async Task ExecuteMethod_WithAJunkAuthorizationHeaderBeforeTheRealOne_IsDenied()
{
// First entry wins, and it must parse. This interceptor originally scanned past a non-Bearer
// header for a later Bearer one while its central-side clone took the first entry, so the
// two clones gave OPPOSITE answers to this exact request. Both now share
// GrpcRequestHeaders and both deny it; pinned identically on CentralCommandAuthInterceptor.
var interceptor = CreateInterceptor("the-cluster-psk");
var context = CreateContext(ExecuteMethod, ["junk", "Bearer the-cluster-psk"]);
var ex = await Should.ThrowAsync<RpcException>(() => Invoke(interceptor, context));
ex.StatusCode.ShouldBe(StatusCode.PermissionDenied);
}
[Fact]
public async Task ExecuteMethod_TokenComparison_IsNotAPrefixMatch()
{
// A prefix/StartsWith comparison would accept a truncated key and make the secret
// recoverable one character at a time.
var interceptor = CreateInterceptor("the-cluster-psk");
var context = CreateContext(ExecuteMethod, "Bearer the-cluster-ps");
var ex = await Should.ThrowAsync<RpcException>(() => Invoke(interceptor, context));
ex.StatusCode.ShouldBe(StatusCode.PermissionDenied);
}
[Fact]
public async Task ServerStreamingPath_IsAlsoGated()
{
// Execute 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("the-cluster-psk");
var context = CreateContext(ExecuteMethod, "Bearer the-wrong-psk");
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("the-cluster-psk");
var context = CreateContext(ExecuteMethod, "Bearer the-wrong-psk");
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("the-cluster-psk");
var context = CreateContext(ExecuteMethod, "Bearer the-wrong-psk");
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(MeshCommandAuthInterceptor).GetConstructors();
publicCtors.Length.ShouldBe(1);
}
/// <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;
}
}