From 306ea1aecfc3eb87003ee5da8063b7b0e7445e0b Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 20 Aug 2026 10:20:56 -0400 Subject: [PATCH] test(host): driver-only nodes carry no authorization middleware (03/S14 exemption pinned) MeshGrpcDriverOnlyPipelineTests - the driver-role shape (no AddOtOpcUaAuth, no UseAuthentication/UseAuthorization at all) behaves identically to the fused shape's T4 positive/negative tests for NodeCommandService and TelemetryStreamService: a correctly-keyed call succeeds, a no-key call gets the interceptor's PermissionDenied + detail string. Also asserts the STRUCTURAL reason it is unaffected - no IAuthorizationPolicyProvider registered at all - so a future "register auth everywhere for consistency" change fails this test before it ever reaches a live site outage. Confirmed (by temporarily swapping in the pre-T2/T3 mapper) that this class passes identically on both builds: driver-only nodes were never exposed to 03/S14 in the first place, with or without .AllowAnonymous(). --- .../MeshGrpcDriverOnlyPipelineTests.cs | 202 ++++++++++++++++++ 1 file changed, 202 insertions(+) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/MeshGrpcDriverOnlyPipelineTests.cs diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/MeshGrpcDriverOnlyPipelineTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/MeshGrpcDriverOnlyPipelineTests.cs new file mode 100644 index 00000000..3d394c43 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/MeshGrpcDriverOnlyPipelineTests.cs @@ -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; + +/// +/// R3-01 T6 — pins the report's "driver-only nodes are structurally unaffected" finding +/// (archreview 03/S14): a node with no admin role never registers +/// AddOtOpcUaAuth/UseAuthentication/UseAuthorization at all, so it has no +/// cookie FallbackPolicy to opt out of in the first place — the PSK interceptors were +/// always the ONLY gate on this shape, with or without .AllowAnonymous(). +/// +/// +/// 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 +/// 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. +/// +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 _nodeInbox = null!; + + /// + 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(o => o.GrpcPsk = NodeCommandKey); + builder.Services.Configure(o => o.ApiKey = TelemetryKey); + builder.Services.AddSingleton(); + + _system = ActorSystem.Create("mesh-driveronly-" + Guid.NewGuid().ToString("N")); + _nodeInbox = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var registry = new ActorRegistry(); + registry.Register(_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(); + o.Interceptors.Add(); + }); + + _port = GetFreePort(); + builder.WebHost.ConfigureKestrel(k => k.ListenAnyIP(_port, o => o.Protocols = HttpProtocols.Http2)); + + _app = builder.Build(); + _hub = _app.Services.GetRequiredService(); + + // 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); + } + + /// + 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().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().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(() => 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( + () => 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; + } + + /// 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)); + } +}