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