b05ebd6353
.AllowAnonymous() on DeploymentArtifactGrpcService, CentralCommandGrpcService, TelemetryStreamGrpcService and NodeCommandGrpcService — the four MapGrpcService maps that are chainable. On a login-enabled admin node the global cookie FallbackPolicy (RequireAuthenticatedUser) applies to every endpoint without explicit authorization metadata, gRPC included; the PSK bearer interceptors run INSIDE the endpoint invocation, strictly after that middleware, so a correctly-keyed node call was challenged before the interceptor's verdict could matter. Safe because all four interceptors fail closed on an unset key. The census now asserts IAllowAnonymous on all four (red before this change, green after) plus a scope guard proving the opt-out never leaks onto an unrelated endpoint. MapZbLocalDbSync (T3) is not chainable the same way and is asserted absent here on purpose, so this test cannot start passing for the wrong reason once T3 lands.
227 lines
11 KiB
C#
227 lines
11 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 carries a route pattern beginning with <c>/</c>, and 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.
|
|
/// </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_AllFourGrpcServiceMapsCarryAllowAnonymous_LocalDbSyncIsNotYetOwnedHere()
|
|
{
|
|
// T3 owns the LocalDbSync map (MapZbLocalDbSync returns IEndpointRouteBuilder, not
|
|
// IEndpointConventionBuilder, so .AllowAnonymous() cannot be chained onto it the same way).
|
|
// Asserting its ABSENCE here — rather than skipping it — is what makes this test go red the
|
|
// moment T3's fix lands with the wrong scope (e.g. anonymising nothing, or the group leaking
|
|
// beyond LocalDbSync) instead of silently starting to pass for the wrong reason.
|
|
var endpoints = RealMethodEndpoints(BuildApp(
|
|
hasAdmin: true, hasDriver: true,
|
|
syncListenPort: 4054, configServeGrpcPort: 4055, telemetryListenPort: 4056));
|
|
|
|
AnonymousPatternsOf(endpoints).ShouldBe(
|
|
[ArtifactFetch, CentralReportApply, TelemetrySubscribe, NodeExecute], ignoreOrder: true);
|
|
}
|
|
|
|
[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). Both carry a route pattern that never starts with <c>/</c>, which is what this
|
|
/// filters on.
|
|
/// </summary>
|
|
private static List<RouteEndpoint> RealMethodEndpoints(WebApplication app) =>
|
|
((IEndpointRouteBuilder)app).DataSources
|
|
.SelectMany(d => d.Endpoints)
|
|
.OfType<RouteEndpoint>()
|
|
.Where(e => e.RoutePattern.RawText?.StartsWith('/') == true)
|
|
.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();
|
|
}
|