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; /// /// 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(); // 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( () => 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( () => 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( () => 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(() => 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; } /// /// 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)); } }