From 458c84c34f0c8136f4d24a55aff5f80ed98c4d46 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 20 Aug 2026 10:06:47 -0400 Subject: [PATCH] test(host): mesh gRPC boundary under the production auth stack (FallbackPolicy live) MeshGrpcAuthStackTests - the CI-shaped guard 03/S14 asked for. A real AddOtOpcUaAuth(config) + UseAuthentication/UseAuthorization pipeline (cookie scheme, DisableLogin=false, EnvironmentName=Production) with the production interceptor set, driving DeploymentArtifactService.Fetch, CentralCommandService.ReportApply and NodeCommandService.Execute through MapOtOpcUaMeshGrpcServices. Verified red-then-green by hand: with the T1 (pre-AllowAnonymous) mapper swapped in, all three positive calls fail with "HTTP/2 server reset the stream, PROTOCOL_ERROR" (RpcException/Internal) - the cookie challenge breaking prior-knowledge h2c, not a clean auth denial. With the current mapper (T2/T3) all three succeed. That asymmetry is the only thing distinguishing the two builds; every other assertion here (cookie handler identity, the falsifiability control) is unchanged by the fix and proves nothing on its own. Falsifiability control: LocalDbSyncService is mapped directly (bypassing MapOtOpcUaMeshGrpcServices) with no .AllowAnonymous(), and a correctly-shaped call to it is refused - proving the fixture's auth stack is genuinely live, not accidentally permissive. --- .../MeshGrpcAuthStackTests.cs | 306 ++++++++++++++++++ 1 file changed, 306 insertions(+) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/MeshGrpcAuthStackTests.cs diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/MeshGrpcAuthStackTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/MeshGrpcAuthStackTests.cs new file mode 100644 index 00000000..d55a27d3 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/MeshGrpcAuthStackTests.cs @@ -0,0 +1,306 @@ +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.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; + +/// +/// R3-01 T4 — the CI-shaped guard that would have caught 03/S14: mesh gRPC calls through the +/// REAL AddOtOpcUaAuth + UseAuthentication/UseAuthorization stack, not a +/// hand-built pipeline that never installs the middleware that broke production. +/// +/// +/// +/// The load-bearing assertion is the ALLOW. A blocked call and a refused call both +/// "fail"; only a correctly-keyed call SUCCEEDING through the real cookie +/// FallbackPolicy proves the .AllowAnonymous() 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. +/// +/// +/// Negative control (anti-vacuity). is mapped directly +/// here — via a bare MapGrpcService, deliberately NOT through +/// MapOtOpcUaMeshGrpcServices — with no .AllowAnonymous(). 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. +/// +/// +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 _centralInbox = null!; + private TaskCompletionSource _nodeInbox = null!; + + /// + 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 (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(o => o.UseInMemoryDatabase(dbName)); + builder.Services.AddScoped(sp => + sp.GetRequiredService>().CreateDbContext()); + + builder.Configuration.AddInMemoryCollection(new Dictionary + { + // 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(o => o.ApiKey = ConfigServeKey); + builder.Services.Configure(o => o.GrpcPsk = NodeCommandKey); + builder.Services.Configure(o => o.ApiKey = TelemetryKey); + builder.Services.AddSingleton(new FixedPskProvider(ClusterId, CentralCommandKey)); + builder.Services.AddSingleton(); + + _system = ActorSystem.Create("mesh-auth-stack-" + Guid.NewGuid().ToString("N")); + _centralInbox = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _nodeInbox = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var registry = new ActorRegistry(); + registry.Register(_system.ActorOf(CaptureActor.Props(_centralInbox))); + registry.Register(_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(); + o.Interceptors.Add(); + o.Interceptors.Add(); + o.Interceptors.Add(); + }); + + _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(); + + await _app.StartAsync(TestContext.Current.CancellationToken); + } + + /// + 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(); + 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>(); + 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().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().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(); + + await Should.ThrowAsync( + () => call.ResponseStream.MoveNext(TestContext.Current.CancellationToken)); + } + + // --------------------------------------------------------------------------------------- + // 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; + } + + /// + /// Hands out the one cluster key expects. Stands + /// in for the real MeshPskProvider (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 MeshPskProviderTests' job). + /// + 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 clusterIds) { } + + public void Invalidate(string clusterId2) { } + + public void InvalidateAll() { } + } + + /// Completes an inbox with the first message it receives. + private sealed class CaptureActor : ReceiveActor + { + public static Props Props(TaskCompletionSource sink) => + Akka.Actor.Props.Create(() => new CaptureActor(sink)); + + public CaptureActor(TaskCompletionSource sink) => ReceiveAny(m => sink.TrySetResult(m)); + } +}