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().
This commit is contained in:
+202
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// R3-01 T6 — pins the report's "driver-only nodes are structurally unaffected" finding
|
||||
/// (archreview 03/S14): a node with no <c>admin</c> role never registers
|
||||
/// <c>AddOtOpcUaAuth</c>/<c>UseAuthentication</c>/<c>UseAuthorization</c> at all, so it has no
|
||||
/// cookie <c>FallbackPolicy</c> to opt out of in the first place — the PSK interceptors were
|
||||
/// always the ONLY gate on this shape, with or without <c>.AllowAnonymous()</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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 <see cref="IAuthorizationPolicyProvider"/>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
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<object> _nodeInbox = null!;
|
||||
|
||||
/// <inheritdoc />
|
||||
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<MeshTransportOptions>(o => o.GrpcPsk = NodeCommandKey);
|
||||
builder.Services.Configure<TelemetryOptions>(o => o.ApiKey = TelemetryKey);
|
||||
builder.Services.AddSingleton<ITelemetryLocalHub, TelemetryLocalHub>();
|
||||
|
||||
_system = ActorSystem.Create("mesh-driveronly-" + Guid.NewGuid().ToString("N"));
|
||||
_nodeInbox = new TaskCompletionSource<object>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var registry = new ActorRegistry();
|
||||
registry.Register<NodeCommunicationActorKey>(_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<TelemetryStreamAuthInterceptor>();
|
||||
o.Interceptors.Add<MeshCommandAuthInterceptor>();
|
||||
});
|
||||
|
||||
_port = GetFreePort();
|
||||
builder.WebHost.ConfigureKestrel(k => k.ListenAnyIP(_port, o => o.Protocols = HttpProtocols.Http2));
|
||||
|
||||
_app = builder.Build();
|
||||
_hub = _app.Services.GetRequiredService<ITelemetryLocalHub>();
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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<IAuthorizationPolicyProvider>().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<RestartDriver>().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<RpcException>(() => 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<RpcException>(
|
||||
() => 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;
|
||||
}
|
||||
|
||||
/// <summary>Completes an inbox with the first message it receives.</summary>
|
||||
private sealed class CaptureActor : ReceiveActor
|
||||
{
|
||||
public static Props Props(TaskCompletionSource<object> sink) =>
|
||||
Akka.Actor.Props.Create(() => new CaptureActor(sink));
|
||||
|
||||
public CaptureActor(TaskCompletionSource<object> sink) => ReceiveAny(m => sink.TrySetResult(m));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user