e3cf363703
MapZbLocalDbSync returns IEndpointRouteBuilder, not an IEndpointConventionBuilder, so .AllowAnonymous() cannot be chained onto it the way the other four mesh maps are (T2). app.MapGroup(string.Empty).AllowAnonymous().MapZbLocalDbSync() carries the same convention while leaving the gRPC route pattern byte-identical - verified directly (LocalDbSync_AllowAnonymousGroup_LeavesTheRoutePatternByteIdentical). The census's RealMethodEndpoints filter had to move off a "route pattern starts with /" heuristic to an exact-route-pattern-set match: inside the empty-prefix group, grpc-dotnet's own "Unimplemented service"/"Unimplemented method" fallback endpoints also gain a leading "/", so the heuristic let them back in and over-counted (caught by DriverOnly_WithASyncPort_AlsoMapsTheLocalDbSyncEndpoint and LocalDbSync_AllowAnonymousGroup_LeavesTheRoutePatternByteIdentical going red against the naive filter before this fix). All five mesh gRPC endpoints now carry IAllowAnonymous (archreview 03/S14, T3).
242 lines
12 KiB
C#
242 lines
12 KiB
C#
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();
|
|
}
|