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:
+241
@@ -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));
|
||||
}
|
||||
}
|
||||
+202
@@ -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<T>()</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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user