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; /// /// R3-01 (T1-T3) — an endpoint-metadata census over MapOtOpcUaMeshGrpcServices, driven in /// all three role shapes a node can actually carry (admin-only, driver-only, fused). /// /// /// /// Why this drives the PRODUCTION mapper, not a hand-copied shape. 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 — MeshGrpcBoundaryTests, /// ConfigServeListenerTests and LocalDbSyncListenerTests all reproduce the /// author's mental model of the mapping rather than the host's actual composition. This /// census calls — the SAME method /// Program.cs calls — so it is structurally impossible for the guard to assert a shape /// production no longer has. /// /// /// Filters out grpc-dotnet's own catch-all infrastructure. Every MapGrpcService /// 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 Unimplemented regardless of /// caller identity), so their authorization metadata carries no security meaning. /// 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 MapGroup (T3) is in play. /// /// /// Guard-of-the-guard. 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. /// /// 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"; /// The five known mesh gRPC method prefixes — the ONLY endpoints this map may anonymise. 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().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() .Single(e => e.RoutePattern.RawText == "/sentinel"); sentinelEndpoint.Metadata.GetMetadata().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; } /// /// 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 MapGroup the LocalDbSync map /// (T3) uses, grpc-dotnet's own fallback patterns ALSO gain a leading /, so a /// prefix-shaped filter would let them back in. /// private static List RealMethodEndpoints(WebApplication app) => ((IEndpointRouteBuilder)app).DataSources .SelectMany(d => d.Endpoints) .OfType() .Where(e => AllFivePrefixes.Contains(e.RoutePattern.RawText)) .ToList(); private static string RoutePatternOf(RouteEndpoint e) => e.RoutePattern.RawText!; /// Route patterns of the endpoints in that carry . private static List AnonymousPatternsOf(IEnumerable endpoints) => endpoints .Where(e => e.Metadata.GetMetadata() is not null) .Select(RoutePatternOf) .ToList(); }