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)); } }