Merge branch 'fix/r3-01-mesh-grpc-allowanonymous' — R3-01: mesh gRPC + LocalDb sync endpoints opt out of the AdminUI cookie FallbackPolicy (PSK interceptors are the gate); real-auth-stack boundary tests + map-convention guard; rig DisableLogin parameterised (archreview 03/S14 Critical)

This commit is contained in:
Joseph Doherty
2026-08-20 10:43:49 -04:00
12 changed files with 1230 additions and 53 deletions
@@ -0,0 +1,241 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
/// <summary>
/// R3-01 (T1-T3) — an endpoint-metadata census over <c>MapOtOpcUaMeshGrpcServices</c>, driven in
/// all three role shapes a node can actually carry (admin-only, driver-only, fused).
/// </summary>
/// <remarks>
/// <para>
/// <b>Why this drives the PRODUCTION mapper, not a hand-copied shape.</b> The Critical this
/// plan fixes (archreview 03/S14) survived seven phases of live gates precisely because every
/// prior test built its own pipeline by hand — <c>MeshGrpcBoundaryTests</c>,
/// <c>ConfigServeListenerTests</c> and <c>LocalDbSyncListenerTests</c> all reproduce the
/// author's mental model of the mapping rather than the host's actual composition. This
/// census calls <see cref="MeshGrpcEndpoints.MapOtOpcUaMeshGrpcServices"/> — the SAME method
/// <c>Program.cs</c> calls — so it is structurally impossible for the guard to assert a shape
/// production no longer has.
/// </para>
/// <para>
/// <b>Filters out grpc-dotnet's own catch-all infrastructure.</b> Every <c>MapGrpcService</c>
/// call also registers a per-service "Unimplemented method for X" fallback, and the FIRST
/// such call in an app additionally registers one shared "Unimplemented service" catch-all.
/// Neither ever reaches real logic (they always answer <c>Unimplemented</c> regardless of
/// caller identity), so their authorization metadata carries no security meaning.
/// <see cref="RealMethodEndpoints"/> keeps only the five real, callable RPC methods, matched
/// against the exact known route-pattern set rather than a route-pattern-shape heuristic —
/// see that method's remarks for why a shape-based filter is NOT safe once LocalDbSync's
/// empty-prefix <c>MapGroup</c> (T3) is in play.
/// </para>
/// <para>
/// <b>Guard-of-the-guard.</b> Every assertion below pins an EXACT expected count and the
/// exact set of route patterns, never just "not empty" — a census that silently found zero
/// endpoints (a typo'd role gate, a helper that never got called) would pass a bare
/// non-empty check trivially. It cannot pass an exact-count-and-set check by accident.
/// </para>
/// </remarks>
public sealed class MeshGrpcAnonymousMetadataTests
{
private const string ArtifactFetch = "/deployment_artifact.v1.DeploymentArtifactService/Fetch";
private const string CentralReportApply = "/mesh_transport.v1.CentralCommandService/ReportApply";
private const string TelemetrySubscribe = "/telemetry.v1.TelemetryStreamService/Subscribe";
private const string NodeExecute = "/mesh_transport.v1.NodeCommandService/Execute";
private const string LocalDbSyncSync = "/localdb_sync.v1.LocalDbSync/Sync";
/// <summary>The five known mesh gRPC method prefixes — the ONLY endpoints this map may anonymise.</summary>
private static readonly string[] AllFivePrefixes =
[LocalDbSyncSync, ArtifactFetch, CentralReportApply, TelemetrySubscribe, NodeExecute];
[Fact]
public void AdminOnly_MapsExactlyTheConfigServeEndpoints()
{
// Admin-only: no driver role, so neither LocalDbSync nor the telemetry/node-command pair are
// mapped even though a port is configured for them below (0 here, but the shape is the point).
var endpoints = RealMethodEndpoints(BuildApp(
hasAdmin: true, hasDriver: false,
syncListenPort: 0, configServeGrpcPort: 4055, telemetryListenPort: 0));
endpoints.Count.ShouldBe(2);
endpoints.Select(RoutePatternOf).ShouldBe([ArtifactFetch, CentralReportApply], ignoreOrder: true);
}
[Fact]
public void DriverOnly_WithNoSyncPort_MapsExactlyTheTelemetryAndNodeCommandEndpoints()
{
var endpoints = RealMethodEndpoints(BuildApp(
hasAdmin: false, hasDriver: true,
syncListenPort: 0, configServeGrpcPort: 0, telemetryListenPort: 4056));
endpoints.Count.ShouldBe(2);
endpoints.Select(RoutePatternOf).ShouldBe([TelemetrySubscribe, NodeExecute], ignoreOrder: true);
}
[Fact]
public void DriverOnly_WithASyncPort_AlsoMapsTheLocalDbSyncEndpoint()
{
var endpoints = RealMethodEndpoints(BuildApp(
hasAdmin: false, hasDriver: true,
syncListenPort: 4054, configServeGrpcPort: 0, telemetryListenPort: 4056));
endpoints.Count.ShouldBe(3);
endpoints.Select(RoutePatternOf).ShouldBe(
[LocalDbSyncSync, TelemetrySubscribe, NodeExecute], ignoreOrder: true);
}
[Fact]
public void Fused_WithAllThreePorts_MapsAllFiveEndpoints()
{
// The docker-dev central pair's shape (admin,driver,cluster-MAIN) once all three dedicated
// h2c ports are configured — the shape 03/S14 called out as breaking the deploy plane for
// the whole fleet, not only the four sites.
var endpoints = RealMethodEndpoints(BuildApp(
hasAdmin: true, hasDriver: true,
syncListenPort: 4054, configServeGrpcPort: 4055, telemetryListenPort: 4056));
endpoints.Count.ShouldBe(5);
endpoints.Select(RoutePatternOf).ShouldBe(AllFivePrefixes, ignoreOrder: true);
}
[Fact]
public void NeitherRoleNorAnyPort_MapsNothing()
{
// The default-OFF shape: every dedicated port is 0. Nothing about the mesh gRPC surface
// should exist on a node that configured none of it.
var endpoints = RealMethodEndpoints(BuildApp(
hasAdmin: false, hasDriver: false,
syncListenPort: 0, configServeGrpcPort: 0, telemetryListenPort: 0));
endpoints.ShouldBeEmpty();
}
// ---------------------------------------------------------------------------------------
// 03/S14 — every mesh endpoint carries IAllowAnonymous, so the AdminUI cookie FallbackPolicy
// (RequireAuthenticatedUser) never reaches it; the PSK interceptors are the ONLY gate. T3
// (LocalDbSync's empty-prefix group) is asserted separately below.
// ---------------------------------------------------------------------------------------
[Fact]
public void AdminOnly_BothConfigServeEndpoints_CarryAllowAnonymous()
{
var endpoints = RealMethodEndpoints(BuildApp(
hasAdmin: true, hasDriver: false,
syncListenPort: 0, configServeGrpcPort: 4055, telemetryListenPort: 0));
AnonymousPatternsOf(endpoints).ShouldBe([ArtifactFetch, CentralReportApply], ignoreOrder: true);
}
[Fact]
public void DriverOnly_BothTelemetryAndNodeCommandEndpoints_CarryAllowAnonymous()
{
var endpoints = RealMethodEndpoints(BuildApp(
hasAdmin: false, hasDriver: true,
syncListenPort: 0, configServeGrpcPort: 0, telemetryListenPort: 4056));
AnonymousPatternsOf(endpoints).ShouldBe([TelemetrySubscribe, NodeExecute], ignoreOrder: true);
}
[Fact]
public void Fused_AllFiveMeshEndpoints_CarryAllowAnonymous()
{
// Includes LocalDbSync (T3): MapZbLocalDbSync returns IEndpointRouteBuilder, not an
// IEndpointConventionBuilder, so .AllowAnonymous() cannot be chained onto it the way the
// other four are — it needs the empty-prefix MapGroup wrapper instead.
var endpoints = RealMethodEndpoints(BuildApp(
hasAdmin: true, hasDriver: true,
syncListenPort: 4054, configServeGrpcPort: 4055, telemetryListenPort: 4056));
AnonymousPatternsOf(endpoints).ShouldBe(AllFivePrefixes, ignoreOrder: true);
}
[Fact]
public void LocalDbSync_AllowAnonymousGroup_LeavesTheRoutePatternByteIdentical()
{
// The empty-prefix MapGroup wrapper (T3) must be pure convention-carrying — it must not
// change the gRPC route pattern LocalDbSyncAuthInterceptor and the sync client both key on.
var endpoints = RealMethodEndpoints(BuildApp(
hasAdmin: false, hasDriver: true,
syncListenPort: 4054, configServeGrpcPort: 0, telemetryListenPort: 0));
var sync = endpoints.ShouldHaveSingleItem();
sync.RoutePattern.RawText.ShouldBe(LocalDbSyncSync);
sync.Metadata.GetMetadata<IAllowAnonymous>().ShouldNotBeNull();
}
[Fact]
public void AllowAnonymous_NeverLeaksBeyondTheFiveKnownMeshEndpoints()
{
// Scope guard (theme 2): an endpoint mapped ALONGSIDE the mesh services, through no seam this
// extension touches, must never come out anonymous. If it did, some future edit widened the
// AllowAnonymous convention beyond the five known prefixes — the exact failure class the
// fallback-policy opt-out exists to bound.
var builder = WebApplication.CreateBuilder();
builder.Environment.ApplicationName = typeof(MeshGrpcAnonymousMetadataTests).Assembly.GetName().Name!;
builder.Services.AddGrpc();
var app = builder.Build();
app.MapOtOpcUaMeshGrpcServices(
hasAdmin: true, hasDriver: true,
syncListenPort: 4054, configServeGrpcPort: 4055, telemetryListenPort: 4056);
var sentinel = app.MapGet("/sentinel", () => "ok");
var sentinelEndpoint = ((IEndpointRouteBuilder)app).DataSources
.SelectMany(d => d.Endpoints)
.OfType<RouteEndpoint>()
.Single(e => e.RoutePattern.RawText == "/sentinel");
sentinelEndpoint.Metadata.GetMetadata<IAllowAnonymous>().ShouldBeNull();
_ = sentinel; // the builder handle itself is not the assertion target
}
// ---------------------------------------------------------------------------------------
// harness
// ---------------------------------------------------------------------------------------
private static WebApplication BuildApp(
bool hasAdmin,
bool hasDriver,
int syncListenPort,
int configServeGrpcPort,
int telemetryListenPort)
{
var builder = WebApplication.CreateBuilder();
builder.Environment.ApplicationName = typeof(MeshGrpcAnonymousMetadataTests).Assembly.GetName().Name!;
builder.Services.AddGrpc();
var app = builder.Build();
app.MapOtOpcUaMeshGrpcServices(
hasAdmin, hasDriver, syncListenPort, configServeGrpcPort, telemetryListenPort);
return app;
}
/// <summary>
/// The real, callable RPC method endpoints — excludes grpc-dotnet's per-app "Unimplemented
/// service" catch-all and per-service "Unimplemented method for X" fallback (see class
/// remarks). Filtered against the exact set of known method route patterns rather than a
/// "starts with /" heuristic: inside the empty-prefix <c>MapGroup</c> the LocalDbSync map
/// (T3) uses, grpc-dotnet's own fallback patterns ALSO gain a leading <c>/</c>, so a
/// prefix-shaped filter would let them back in.
/// </summary>
private static List<RouteEndpoint> RealMethodEndpoints(WebApplication app) =>
((IEndpointRouteBuilder)app).DataSources
.SelectMany(d => d.Endpoints)
.OfType<RouteEndpoint>()
.Where(e => AllFivePrefixes.Contains(e.RoutePattern.RawText))
.ToList();
private static string RoutePatternOf(RouteEndpoint e) => e.RoutePattern.RawText!;
/// <summary>Route patterns of the endpoints in <paramref name="endpoints"/> that carry <see cref="IAllowAnonymous"/>.</summary>
private static List<string> AnonymousPatternsOf(IEnumerable<RouteEndpoint> endpoints) =>
endpoints
.Where(e => e.Metadata.GetMetadata<IAllowAnonymous>() is not null)
.Select(RoutePatternOf)
.ToList();
}
@@ -0,0 +1,395 @@
using System.Diagnostics.CodeAnalysis;
using System.Net;
using Akka.Actor;
using Akka.Hosting;
using Grpc.Core;
using Grpc.Net.Client;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Server.Kestrel.Core;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Shouldly;
using Xunit;
using ZB.MOM.WW.LocalDb.Replication;
using ZB.MOM.WW.OtOpcUa.Cluster;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Mesh;
using ZB.MOM.WW.OtOpcUa.Commons.Protos.DeploymentArtifact.V1;
using ZB.MOM.WW.OtOpcUa.Commons.Protos.MeshTransport.V1;
using ZB.MOM.WW.OtOpcUa.Commons.Protos.Telemetry.V1;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.ControlPlane;
using ZB.MOM.WW.OtOpcUa.ControlPlane.Communication;
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
using ZB.MOM.WW.OtOpcUa.Runtime;
using ZB.MOM.WW.OtOpcUa.Runtime.Telemetry;
using ZB.MOM.WW.OtOpcUa.Security;
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
/// <summary>
/// R3-01 T4 — the CI-shaped guard that would have caught 03/S14: mesh gRPC calls through the
/// REAL <c>AddOtOpcUaAuth</c> + <c>UseAuthentication</c>/<c>UseAuthorization</c> stack, not a
/// hand-built pipeline that never installs the middleware that broke production.
/// </summary>
/// <remarks>
/// <para>
/// <b>The load-bearing assertion is the ALLOW.</b> A blocked call and a refused call both
/// "fail"; only a correctly-keyed call SUCCEEDING through the real cookie
/// <c>FallbackPolicy</c> proves the <c>.AllowAnonymous()</c> opt-outs (T2/T3) actually work in
/// a pipeline that carries the policy at all. Every positive test here failed before T2/T3 —
/// confirmed by running this class against the T1 commit, where every one of them was
/// challenged by the cookie handler instead of reaching the interceptor.
/// </para>
/// <para>
/// <b>Negative control (anti-vacuity).</b> <see cref="LocalDbSyncService"/> is mapped directly
/// here — via a bare <c>MapGrpcService</c>, deliberately NOT through
/// <c>MapOtOpcUaMeshGrpcServices</c> — with no <c>.AllowAnonymous()</c>. A correctly-shaped
/// call to it must be refused by the cookie challenge. If it were not, the fixture's auth
/// stack would be inert and every positive assertion above would be proving nothing.
/// </para>
/// </remarks>
public sealed class MeshGrpcAuthStackTests : IAsyncLifetime
{
private const string ConfigServeKey = "test-configserve-key";
private const string NodeCommandKey = "test-node-psk";
private const string CentralCommandKey = "test-central-psk";
private const string TelemetryKey = "test-telemetry-key";
private const string ClusterId = "MAIN";
private WebApplication _app = null!;
private ActorSystem _system = null!;
private int _port;
private TaskCompletionSource<object> _centralInbox = null!;
private TaskCompletionSource<object> _nodeInbox = null!;
/// <inheritdoc />
public async ValueTask InitializeAsync()
{
var builder = WebApplication.CreateBuilder();
builder.Environment.ApplicationName = typeof(MeshGrpcAuthStackTests).Assembly.GetName().Name!;
// Production, not the WebApplicationBuilder default: forces ValidateOnBuild/ValidateScopes OFF
// (they engage only under Development), so services never resolved without a login attempt
// (ILdapAuthService's graph) don't need to be fully stood up for this fixture to build.
builder.Environment.EnvironmentName = Environments.Production;
builder.Logging.ClearProviders();
// DataProtection's PersistKeysToDbContext<OtOpcUaConfigDbContext> (inside AddOtOpcUaAuth)
// resolves a SCOPED OtOpcUaConfigDbContext via CreateScope() + GetRequiredService — it has no
// factory support. Mirrors AddOtOpcUaConfigDb's own factory+scoped-bridge shape, over InMemory.
var dbName = Guid.NewGuid().ToString("N");
builder.Services.AddDbContextFactory<OtOpcUaConfigDbContext>(o => o.UseInMemoryDatabase(dbName));
builder.Services.AddScoped(sp =>
sp.GetRequiredService<IDbContextFactory<OtOpcUaConfigDbContext>>().CreateDbContext());
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
// The whole point of this fixture: exercise the REAL cookie handler, not
// AutoLoginAuthenticationHandler. See CookieSchemeIsTheRealHandler_NotAutoLogin.
["Security:Auth:DisableLogin"] = "false",
// This fixture serves cleartext h2c; a HTTPS-only cookie policy is irrelevant to what is
// under test (the FallbackPolicy applies identically either way) and would otherwise log
// a startup Warning for no reason.
["Security:Cookie:RequireHttpsCookie"] = "false",
});
builder.Services.AddOtOpcUaAuth(builder.Configuration);
// Interceptor options bound directly (NOT via AddOtOpcUaCluster, which also wires
// AkkaClusterOptions/ConfigSourceOptions/TelemetryDialOptions validators with ValidateOnStart —
// heavy machinery this fixture has no use for and no seed-node config to satisfy).
builder.Services.Configure<ConfigServeOptions>(o => o.ApiKey = ConfigServeKey);
builder.Services.Configure<MeshTransportOptions>(o => o.GrpcPsk = NodeCommandKey);
builder.Services.Configure<TelemetryOptions>(o => o.ApiKey = TelemetryKey);
builder.Services.AddSingleton<IMeshPskProvider>(new FixedPskProvider(ClusterId, CentralCommandKey));
builder.Services.AddSingleton<ITelemetryLocalHub, TelemetryLocalHub>();
_system = ActorSystem.Create("mesh-auth-stack-" + Guid.NewGuid().ToString("N"));
_centralInbox = new TaskCompletionSource<object>(TaskCreationOptions.RunContinuationsAsynchronously);
_nodeInbox = new TaskCompletionSource<object>(TaskCreationOptions.RunContinuationsAsynchronously);
var registry = new ActorRegistry();
registry.Register<CentralCommunicationActorKey>(_system.ActorOf(CaptureActor.Props(_centralInbox)));
registry.Register<NodeCommunicationActorKey>(_system.ActorOf(CaptureActor.Props(_nodeInbox)));
builder.Services.AddSingleton(registry);
// The production interceptor set — the ONLY inbound auth on the gRPC side. Deliberately does
// NOT include LocalDbSyncAuthInterceptor: LocalDbSyncService is mapped below purely as the
// negative control and is never given a matching interceptor here.
builder.Services.AddGrpc(o =>
{
o.Interceptors.Add<ConfigServeAuthInterceptor>();
o.Interceptors.Add<CentralCommandAuthInterceptor>();
o.Interceptors.Add<TelemetryStreamAuthInterceptor>();
o.Interceptors.Add<MeshCommandAuthInterceptor>();
});
_port = GetFreePort();
builder.WebHost.ConfigureKestrel(k => k.ListenAnyIP(_port, o => o.Protocols = HttpProtocols.Http2));
_app = builder.Build();
_app.UseAuthentication();
_app.UseAuthorization();
// The production mapper — the four chainable maps carry AllowAnonymous (T2); LocalDbSync is
// NOT mapped here (syncListenPort: 0) so it stays free for the negative control below without
// a route collision.
_app.MapOtOpcUaMeshGrpcServices(
hasAdmin: true, hasDriver: true,
syncListenPort: 0, configServeGrpcPort: 1, telemetryListenPort: 1);
// Negative control (T4): mapped directly, NOT through MapOtOpcUaMeshGrpcServices, and
// deliberately with no .AllowAnonymous().
_app.MapGrpcService<LocalDbSyncService>();
await _app.StartAsync(TestContext.Current.CancellationToken);
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
await _app.StopAsync(TimeSpan.FromSeconds(10));
await _app.DisposeAsync();
await _system.Terminate().WaitAsync(TimeSpan.FromSeconds(15));
}
[Fact]
public async Task CookieSchemeIsTheRealHandler_NotAutoLogin()
{
// If this resolves AutoLoginAuthenticationHandler, every assertion below is meaningless — the
// fallback policy would pass every request regardless of AllowAnonymous.
var provider = _app.Services.GetRequiredService<Microsoft.AspNetCore.Authentication.IAuthenticationSchemeProvider>();
var scheme = await provider.GetSchemeAsync(CookieAuthenticationDefaults.AuthenticationScheme);
scheme.ShouldNotBeNull();
scheme.HandlerType.ShouldBe(typeof(CookieAuthenticationHandler));
}
[Fact]
public async Task CorrectlyKeyed_ArtifactFetch_SucceedsThroughTheRealAuthStack()
{
var deploymentId = Guid.NewGuid();
var blob = "R3-01-T4-artifact-bytes"u8.ToArray();
var factory = _app.Services.GetRequiredService<IDbContextFactory<OtOpcUaConfigDbContext>>();
await using (var db = await factory.CreateDbContextAsync(TestContext.Current.CancellationToken))
{
db.Deployments.Add(new Deployment
{
DeploymentId = deploymentId,
RevisionHash = new string('a', 64),
CreatedBy = "r3-01-t4",
ArtifactBlob = blob,
});
await db.SaveChangesAsync(TestContext.Current.CancellationToken);
}
var client = new DeploymentArtifactService.DeploymentArtifactServiceClient(Channel());
var headers = new Metadata { { "authorization", $"Bearer {ConfigServeKey}" } };
using var call = client.Fetch(
new FetchRequest { DeploymentId = deploymentId.ToString() },
headers,
cancellationToken: TestContext.Current.CancellationToken);
using var buffer = new MemoryStream();
await foreach (var chunk in call.ResponseStream.ReadAllAsync(TestContext.Current.CancellationToken))
chunk.Data.WriteTo(buffer);
buffer.ToArray().ShouldBe(blob);
}
[Fact]
public async Task CorrectlyKeyed_CentralCommandReportApply_SucceedsThroughTheRealAuthStack()
{
var ack = new ApplyAck(
new DeploymentId(Guid.NewGuid()),
new NodeId("node-a"),
ApplyAckOutcome.Applied,
FailureReason: null,
CorrelationId.NewId());
var client = new CentralCommandService.CentralCommandServiceClient(Channel());
var headers = new Metadata
{
{ "authorization", $"Bearer {CentralCommandKey}" },
{ "x-otopcua-cluster", ClusterId },
};
await client.ReportApplyAsync(
MeshTransportDtoMapper.ToProto(ack), headers, cancellationToken: TestContext.Current.CancellationToken);
var received = await _centralInbox.Task.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
received.ShouldBeOfType<ApplyAck>().DeploymentId.ShouldBe(ack.DeploymentId);
}
[Fact]
public async Task CorrectlyKeyed_NodeCommandExecute_SucceedsThroughTheRealAuthStack()
{
var command = new RestartDriver("C1", "driver-1", "alice", Guid.NewGuid());
var client = new NodeCommandService.NodeCommandServiceClient(Channel());
var headers = new Metadata { { "authorization", $"Bearer {NodeCommandKey}" } };
await client.ExecuteAsync(
MeshTransportDtoMapper.ToEnvelope(command), headers, cancellationToken: TestContext.Current.CancellationToken);
var received = await _nodeInbox.Task.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
received.ShouldBeOfType<RestartDriver>().DriverInstanceId.ShouldBe("driver-1");
}
[Fact]
public async Task FalsifiabilityControl_AnUnAnonymisedServiceMapped_RefusesEvenACorrectlyShapedCall()
{
// LocalDbSyncService carries no AllowAnonymous in this fixture (see InitializeAsync). If this
// call succeeds, the fixture's auth stack is not actually challenging unmarked endpoints and
// every "succeeds through the real auth stack" assertion above proves nothing.
var client = new ZB.MOM.WW.LocalDb.Contracts.LocalDbSync.LocalDbSyncClient(Channel());
var headers = new Metadata { { "authorization", "Bearer irrelevant-to-this-endpoint" } };
using var call = client.Sync(headers, cancellationToken: TestContext.Current.CancellationToken);
await call.RequestStream.CompleteAsync();
// Review (R3-01): asserting a bare Exception was vacuous — an anonymised endpoint ALSO throws
// here (handler deps are not wired in this fixture => RpcException Unknown). Only the
// cookie-challenge signature discriminates: the FallbackPolicy's 302 to /login is a
// non-gRPC response, surfaced by grpc-dotnet as Internal + PROTOCOL_ERROR.
var ex = await Should.ThrowAsync<RpcException>(
() => call.ResponseStream.MoveNext(TestContext.Current.CancellationToken));
ex.StatusCode.ShouldBe(StatusCode.Internal);
ex.Status.Detail.ShouldContain("PROTOCOL_ERROR");
}
// ---------------------------------------------------------------------------------------
// T5 - status fidelity: a no-PSK / wrong-PSK call reports the INTERCEPTOR's verdict, never a
// cookie challenge. This is the vacuity trap the plan calls out: ConfigServeAuthInterceptor
// throws StatusCode.Unauthenticated, and Grpc.Net.Client also maps an HTTP 401 to
// Unauthenticated - so "the call failed with Unauthenticated" is true on a broken build AND a
// fixed one. The interceptor's DETAIL string is the only thing that discriminates them.
//
// Observed cookie-challenge signature (captured red-then-green against this same fixture, see
// MeshGrpcAuthStackTests' positive tests): the challenge does not surface as a clean
// Unauthenticated/PermissionDenied at all. Grpc.Net.Client reports
// RpcException(StatusCode.Internal, "Error starting gRPC call. HttpRequestException: The HTTP/2
// server reset the stream. HTTP/2 error code 'PROTOCOL_ERROR' (0x1)..."), because the cookie
// handler's redirect/401 breaks the prior-knowledge h2c stream before any gRPC trailers are
// written. So a status-only assertion here would ALSO be vacuous in the other direction: a
// regressed build fails with StatusCode.Internal, never Unauthenticated/PermissionDenied, and a
// caller checking only "did it throw" cannot tell a blocked call from a refused one. Recorded
// here per the plan (T5 step 2) - this is the exact string the live gate (T10) greps for.
// ---------------------------------------------------------------------------------------
[Theory]
[InlineData(null)]
[InlineData("Bearer the-wrong-key")]
public async Task NoOrWrongPsk_ArtifactFetch_FailsWithTheInterceptorsDetail(string? authHeader)
{
var client = new DeploymentArtifactService.DeploymentArtifactServiceClient(Channel());
var headers = new Metadata();
if (authHeader is not null)
headers.Add("authorization", authHeader);
using var call = client.Fetch(
new FetchRequest { DeploymentId = Guid.NewGuid().ToString() },
headers,
cancellationToken: TestContext.Current.CancellationToken);
var ex = await Should.ThrowAsync<RpcException>(
() => call.ResponseStream.MoveNext(TestContext.Current.CancellationToken));
ex.StatusCode.ShouldBe(StatusCode.Unauthenticated);
ex.Status.Detail.ShouldBe("Config-serve authentication failed.");
}
[Theory]
[InlineData(null)]
[InlineData("Bearer the-wrong-key")]
public async Task NoOrWrongPsk_TelemetrySubscribe_FailsWithTheInterceptorsDetail(string? authHeader)
{
var client = new TelemetryStreamService.TelemetryStreamServiceClient(Channel());
var headers = new Metadata();
if (authHeader is not null)
headers.Add("authorization", authHeader);
using var call = client.Subscribe(
new TelemetryStreamRequest { CorrelationId = "r3-01-t5" },
headers,
cancellationToken: TestContext.Current.CancellationToken);
var ex = await Should.ThrowAsync<RpcException>(
() => call.ResponseStream.MoveNext(TestContext.Current.CancellationToken));
ex.StatusCode.ShouldBe(StatusCode.PermissionDenied);
ex.Status.Detail.ShouldBe("Telemetry stream authentication failed.");
}
[Theory]
[InlineData(null)]
[InlineData("Bearer the-wrong-key")]
public async Task NoOrWrongPsk_NodeCommandExecute_FailsWithTheInterceptorsDetail(string? authHeader)
{
var command = new RestartDriver("C1", "driver-1", "alice", Guid.NewGuid());
var client = new NodeCommandService.NodeCommandServiceClient(Channel());
var headers = new Metadata();
if (authHeader is not null)
headers.Add("authorization", authHeader);
var ex = await Should.ThrowAsync<RpcException>(() => client.ExecuteAsync(
MeshTransportDtoMapper.ToEnvelope(command), headers,
cancellationToken: TestContext.Current.CancellationToken).ResponseAsync);
ex.StatusCode.ShouldBe(StatusCode.PermissionDenied);
ex.Status.Detail.ShouldBe("Node command authentication failed.");
}
// ---------------------------------------------------------------------------------------
// harness
// ---------------------------------------------------------------------------------------
private GrpcChannel Channel() => GrpcChannel.ForAddress($"http://localhost:{_port}");
private static int GetFreePort()
{
using var listener = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
listener.Stop();
return port;
}
/// <summary>
/// Hands out the one cluster key <see cref="CentralCommandAuthInterceptor"/> expects. Stands
/// in for the real <c>MeshPskProvider</c> (secret-store lookups, caching, the known-cluster
/// gate) — out of scope here, where the subject is the HTTP-layer auth stack, not the PSK
/// resolution chain (that is <c>MeshPskProviderTests</c>' job).
/// </summary>
private sealed class FixedPskProvider(string clusterId, string psk) : IMeshPskProvider
{
public bool TryGetPsk(string clusterId2, [NotNullWhen(true)] out string? psk2)
{
psk2 = string.Equals(clusterId2, clusterId, StringComparison.Ordinal) ? psk : null;
return psk2 is not null;
}
public bool Matches(string clusterId2, string presented) =>
string.Equals(clusterId2, clusterId, StringComparison.Ordinal)
&& string.Equals(presented, psk, StringComparison.Ordinal);
public void SetKnownClusters(IReadOnlyCollection<string> clusterIds) { }
public void Invalidate(string clusterId2) { }
public void InvalidateAll() { }
}
/// <summary>Completes an inbox with the first message it receives.</summary>
private sealed class CaptureActor : ReceiveActor
{
public static Props Props(TaskCompletionSource<object> sink) =>
Akka.Actor.Props.Create(() => new CaptureActor(sink));
public CaptureActor(TaskCompletionSource<object> sink) => ReceiveAny(m => sink.TrySetResult(m));
}
}
@@ -0,0 +1,202 @@
using System.Diagnostics.CodeAnalysis;
using System.Net;
using Akka.Actor;
using Akka.Hosting;
using Grpc.Core;
using Grpc.Net.Client;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Server.Kestrel.Core;
using Microsoft.Extensions.DependencyInjection;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Cluster;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Drivers;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Mesh;
using ZB.MOM.WW.OtOpcUa.Commons.Protos.MeshTransport.V1;
using ZB.MOM.WW.OtOpcUa.Commons.Protos.Telemetry.V1;
using ZB.MOM.WW.OtOpcUa.ControlPlane.Communication;
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
using ZB.MOM.WW.OtOpcUa.Runtime;
using ZB.MOM.WW.OtOpcUa.Runtime.Telemetry;
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
/// <summary>
/// R3-01 T6 — pins the report's "driver-only nodes are structurally unaffected" finding
/// (archreview 03/S14): a node with no <c>admin</c> role never registers
/// <c>AddOtOpcUaAuth</c>/<c>UseAuthentication</c>/<c>UseAuthorization</c> at all, so it has no
/// cookie <c>FallbackPolicy</c> to opt out of in the first place — the PSK interceptors were
/// always the ONLY gate on this shape, with or without <c>.AllowAnonymous()</c>.
/// </summary>
/// <remarks>
/// A future "register auth everywhere for consistency" change would silently reintroduce 03/S14
/// for every site node in the fleet. This class is what turns that into a failing test instead of
/// a live outage: it asserts BOTH that the driver-only mesh endpoints behave identically to the
/// fused shape's (T4) AND that the reason they do — no <see cref="IAuthorizationPolicyProvider"/>
/// registered at all — actually holds, so a change that adds auth services here fails on the
/// second assertion even if it somehow left the first passing.
/// </remarks>
public sealed class MeshGrpcDriverOnlyPipelineTests : IAsyncLifetime
{
private const string NodeCommandKey = "test-driveronly-node-psk";
private const string TelemetryKey = "test-driveronly-telemetry-key";
private WebApplication _app = null!;
private ActorSystem _system = null!;
private int _port;
private ITelemetryLocalHub _hub = null!;
private TaskCompletionSource<object> _nodeInbox = null!;
/// <inheritdoc />
public async ValueTask InitializeAsync()
{
var builder = WebApplication.CreateBuilder();
builder.Environment.ApplicationName = typeof(MeshGrpcDriverOnlyPipelineTests).Assembly.GetName().Name!;
builder.Logging.ClearProviders();
// Deliberately NO AddOtOpcUaAuth, NO UseAuthentication, NO UseAuthorization — this IS the
// driver-only shape (Program.cs gates all three on hasAdmin). Nothing here should need them.
builder.Services.Configure<MeshTransportOptions>(o => o.GrpcPsk = NodeCommandKey);
builder.Services.Configure<TelemetryOptions>(o => o.ApiKey = TelemetryKey);
builder.Services.AddSingleton<ITelemetryLocalHub, TelemetryLocalHub>();
_system = ActorSystem.Create("mesh-driveronly-" + Guid.NewGuid().ToString("N"));
_nodeInbox = new TaskCompletionSource<object>(TaskCreationOptions.RunContinuationsAsynchronously);
var registry = new ActorRegistry();
registry.Register<NodeCommunicationActorKey>(_system.ActorOf(CaptureActor.Props(_nodeInbox)));
builder.Services.AddSingleton(registry);
// The driver-role interceptor subset only (matches Program.cs's `if (hasDriver)` branch);
// no ConfigServeAuthInterceptor/CentralCommandAuthInterceptor — those are admin-role.
builder.Services.AddGrpc(o =>
{
o.Interceptors.Add<TelemetryStreamAuthInterceptor>();
o.Interceptors.Add<MeshCommandAuthInterceptor>();
});
_port = GetFreePort();
builder.WebHost.ConfigureKestrel(k => k.ListenAnyIP(_port, o => o.Protocols = HttpProtocols.Http2));
_app = builder.Build();
_hub = _app.Services.GetRequiredService<ITelemetryLocalHub>();
// hasAdmin: false — the driver-only shape. syncListenPort: 0 so LocalDbSync (admin-agnostic
// but out of scope here) is not mapped; configServeGrpcPort: 0 because a driver-only node
// never carries ConfigServe.
_app.MapOtOpcUaMeshGrpcServices(
hasAdmin: false, hasDriver: true,
syncListenPort: 0, configServeGrpcPort: 0, telemetryListenPort: 1);
await _app.StartAsync(TestContext.Current.CancellationToken);
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
await _app.StopAsync(TimeSpan.FromSeconds(10));
await _app.DisposeAsync();
await _system.Terminate().WaitAsync(TimeSpan.FromSeconds(15));
}
[Fact]
public void NoAuthorizationPolicyProviderIsRegistered()
{
// THE structural reason this shape is unaffected: with AddOtOpcUaAuth never called, there is
// no FallbackPolicy to opt out of. If a future change registers authorization services on a
// driver-only node, this goes red before any endpoint's behaviour does.
_app.Services.GetService<IAuthorizationPolicyProvider>().ShouldBeNull();
}
[Fact]
public async Task CorrectlyKeyed_NodeCommandExecute_Succeeds()
{
var command = new RestartDriver("C1", "driver-1", "alice", Guid.NewGuid());
var client = new NodeCommandService.NodeCommandServiceClient(Channel());
var headers = new Metadata { { "authorization", $"Bearer {NodeCommandKey}" } };
await client.ExecuteAsync(
MeshTransportDtoMapper.ToEnvelope(command), headers,
cancellationToken: TestContext.Current.CancellationToken);
var received = await _nodeInbox.Task.WaitAsync(
TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
received.ShouldBeOfType<RestartDriver>().DriverInstanceId.ShouldBe("driver-1");
}
[Fact]
public async Task NoKey_NodeCommandExecute_FailsWithTheInterceptorsDetail()
{
var command = new RestartDriver("C1", "driver-1", "alice", Guid.NewGuid());
var client = new NodeCommandService.NodeCommandServiceClient(Channel());
var ex = await Should.ThrowAsync<RpcException>(() => client.ExecuteAsync(
MeshTransportDtoMapper.ToEnvelope(command), new Metadata(),
cancellationToken: TestContext.Current.CancellationToken).ResponseAsync);
ex.StatusCode.ShouldBe(StatusCode.PermissionDenied);
ex.Status.Detail.ShouldBe("Node command authentication failed.");
}
[Fact]
public async Task CorrectlyKeyed_TelemetrySubscribe_Succeeds()
{
// Health is snapshot-style (ITelemetryLocalHub.Emit / class remarks): emitting BEFORE the
// Subscribe call means the new subscriber's reader is primed with it immediately, with no
// race against a live delta.
_hub.Emit(new TelemetryItem.Health(new DriverHealthChanged(
"C1", "driver-1", "Healthy", DateTime.UtcNow, null, 0, DateTime.UtcNow)));
var client = new TelemetryStreamService.TelemetryStreamServiceClient(Channel());
var headers = new Metadata { { "authorization", $"Bearer {TelemetryKey}" } };
using var call = client.Subscribe(
new TelemetryStreamRequest { CorrelationId = "r3-01-t6" },
headers,
cancellationToken: TestContext.Current.CancellationToken);
(await call.ResponseStream.MoveNext(TestContext.Current.CancellationToken)).ShouldBeTrue();
call.ResponseStream.Current.DriverHealth.DriverInstanceId.ShouldBe("driver-1");
}
[Fact]
public async Task NoKey_TelemetrySubscribe_FailsWithTheInterceptorsDetail()
{
var client = new TelemetryStreamService.TelemetryStreamServiceClient(Channel());
using var call = client.Subscribe(
new TelemetryStreamRequest { CorrelationId = "r3-01-t6-nokey" },
cancellationToken: TestContext.Current.CancellationToken);
var ex = await Should.ThrowAsync<RpcException>(
() => call.ResponseStream.MoveNext(TestContext.Current.CancellationToken));
ex.StatusCode.ShouldBe(StatusCode.PermissionDenied);
ex.Status.Detail.ShouldBe("Telemetry stream authentication failed.");
}
// ---------------------------------------------------------------------------------------
// harness
// ---------------------------------------------------------------------------------------
private GrpcChannel Channel() => GrpcChannel.ForAddress($"http://localhost:{_port}");
private static int GetFreePort()
{
using var listener = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
listener.Stop();
return port;
}
/// <summary>Completes an inbox with the first message it receives.</summary>
private sealed class CaptureActor : ReceiveActor
{
public static Props Props(TaskCompletionSource<object> sink) =>
Akka.Actor.Props.Create(() => new CaptureActor(sink));
public CaptureActor(TaskCompletionSource<object> sink) => ReceiveAny(m => sink.TrySetResult(m));
}
}
@@ -0,0 +1,66 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
/// <summary>
/// R3-01 T7 — a source-scan guard, following the solution-root-walk idiom
/// <c>ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Tests.FlushLoggingConventionTests</c> uses. Complements the
/// endpoint-metadata census (<c>MeshGrpcAnonymousMetadataTests</c>): the census catches a map
/// added to <c>MeshGrpcEndpoints</c> without the <c>AllowAnonymous</c> convention, but it cannot
/// catch a map added to <c>Program.cs</c> that bypasses the helper entirely — that shape never
/// goes through <c>MapOtOpcUaMeshGrpcServices</c> at all, so the census would simply never see it.
/// </summary>
/// <remarks>
/// Would have FAILED at HEAD before T1: <c>Program.cs</c> used to carry the five map sites
/// inline (<c>MapZbLocalDbSync()</c>, four <c>MapGrpcService&lt;T&gt;()</c> calls) with no
/// <c>MapOtOpcUaMeshGrpcServices</c> call at all.
/// </remarks>
public sealed class MeshGrpcMapConventionTests
{
private static readonly string ProgramCsPath = LocateProgramCs();
[Fact]
public void ProgramCs_ContainsNoDirectMeshGrpcMapSites_AndExactlyOneMapperCall()
{
var source = File.ReadAllText(ProgramCsPath);
// Guard-of-the-guard: a bad path (an empty file, or one that never had the mapper in the
// first place) must not pass this test by finding nothing to count.
source.ShouldNotBeNullOrWhiteSpace();
source.ShouldContain("MapOtOpcUaMeshGrpcServices(");
CountOccurrences(source, "MapGrpcService<").ShouldBe(0,
"every MapGrpcService<T>() call must live inside MeshGrpcEndpoints.MapOtOpcUaMeshGrpcServices, " +
"not inline in Program.cs (03/S14 — a map added outside the helper is invisible to the " +
"endpoint-metadata census).");
CountOccurrences(source, "MapZbLocalDbSync(").ShouldBe(0,
"the LocalDb sync map must live inside MeshGrpcEndpoints.MapOtOpcUaMeshGrpcServices, not inline " +
"in Program.cs, for the same reason as MapGrpcService<T>() above.");
CountOccurrences(source, "MapOtOpcUaMeshGrpcServices(").ShouldBe(1,
"Program.cs must call the mapper exactly once; more than one call would map the mesh gRPC " +
"services twice (a route conflict) and zero calls would drop them entirely.");
}
private static int CountOccurrences(string source, string needle)
{
var count = 0;
var index = 0;
while ((index = source.IndexOf(needle, index, StringComparison.Ordinal)) >= 0)
{
count++;
index += needle.Length;
}
return count;
}
private static string LocateProgramCs()
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "ZB.MOM.WW.OtOpcUa.slnx")))
dir = dir.Parent;
dir.ShouldNotBeNull("Could not find solution root (ZB.MOM.WW.OtOpcUa.slnx).");
return Path.Combine(dir!.FullName, "src", "Server", "ZB.MOM.WW.OtOpcUa.Host", "Program.cs");
}
}