Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Grpc/CentralCommandGrpcServiceTests.cs
T
Joseph Doherty e1db90f9c9 fix(mesh): 1B.1 review follow-ups
Two review items from Task 1B.1, both diagnostic:

1. CentralCommandGrpcService's "actor not ready" Warning omitted the peer and
   the correlation id while the decode-failure Warning six lines above carried
   both. These are the only two ways an ack fails to reach the coordinator, and
   an operator has to correlate either against the node's own log. Decoding has
   already succeeded on this path, so the DECODED correlation id is free.

2. A fused driver,admin,cluster-MAIN node under Mode=Grpc owes BOTH validator
   surfaces at once (driver keys AND ConfigServe:GrpcListenPort). The two blocks
   are independent `if`s; chained as `else if` a fused central node would be
   graded on one surface only and boot with nowhere for any ack to land. Pinned
   from both sides plus the fully-configured pass.
2026-08-06 08:42:11 -04:00

267 lines
12 KiB
C#

using Akka.Hosting;
using Akka.TestKit.Xunit2;
using Grpc.Core;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Grpc;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
using ZB.MOM.WW.OtOpcUa.Commons.Protos.MeshTransport.V1;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.ControlPlane;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Grpc;
/// <summary>
/// The central-side gRPC ingress for a node's deployment ack (ClusterClient→gRPC migration
/// Phase 1B). It decodes the wire message and hands it to the registered
/// <c>CentralCommunicationActor</c> — the same actor the ClusterClient receptionist delivered to,
/// whose forward-to-the-coordinator path is untouched.
/// </summary>
/// <remarks>
/// Both failure modes matter more than the happy path. An ack that cannot be decoded must be
/// refused rather than guessed at (a wrong outcome seals a deployment nobody applied), and an ack
/// arriving before the actor exists must say <see cref="StatusCode.Unavailable"/> so the caller
/// retries instead of recording a permanent failure against a node that is fine.
/// </remarks>
public sealed class CentralCommandGrpcServiceTests : TestKit
{
private static ApplyAckMessage WellFormed() => new()
{
DeploymentId = Guid.NewGuid().ToString("N"),
NodeId = "site-a-1",
Outcome = ApplyOutcome.Applied,
CorrelationId = Guid.NewGuid().ToString("N"),
};
private static CentralCommandGrpcService Service(ActorRegistry registry) =>
new(registry, NullLogger<CentralCommandGrpcService>.Instance);
[Fact]
public async Task A_well_formed_ack_is_decoded_and_told_to_the_registered_actor()
{
var probe = CreateTestProbe("central-comm");
var registry = new ActorRegistry();
registry.Register<CentralCommunicationActorKey>(probe.Ref);
var request = WellFormed();
request.FailureReason = "disk full";
request.Outcome = ApplyOutcome.Failed;
var response = await Service(registry).ReportApply(request, new FakeServerCallContext());
response.ShouldNotBeNull();
var received = probe.ExpectMsg<ApplyAck>(TimeSpan.FromSeconds(3));
received.DeploymentId.ShouldBe(DeploymentId.Parse(request.DeploymentId));
received.NodeId.Value.ShouldBe("site-a-1");
received.Outcome.ShouldBe(ApplyAckOutcome.Failed);
received.FailureReason.ShouldBe("disk full");
received.CorrelationId.ToString().ShouldBe(request.CorrelationId);
}
[Fact]
public async Task An_absent_failure_reason_arrives_as_null_and_an_empty_one_as_empty()
{
// Presence, not emptiness — the mapper's contract, asserted at the seam that actually feeds
// the coordinator. Collapsing the two would report an operator-visible blank reason as "no
// reason recorded".
var probe = CreateTestProbe("central-comm");
var registry = new ActorRegistry();
registry.Register<CentralCommunicationActorKey>(probe.Ref);
var service = Service(registry);
await service.ReportApply(WellFormed(), new FakeServerCallContext());
probe.ExpectMsg<ApplyAck>(TimeSpan.FromSeconds(3)).FailureReason.ShouldBeNull();
var blank = WellFormed();
blank.FailureReason = "";
await service.ReportApply(blank, new FakeServerCallContext());
probe.ExpectMsg<ApplyAck>(TimeSpan.FromSeconds(3)).FailureReason.ShouldBe("");
}
[Fact]
public async Task An_unregistered_actor_yields_Unavailable()
{
// The boot window: the listener answers before Akka.Hosting has spawned the actor. Unavailable
// is the retryable status, so the node's next attempt lands instead of the deployment
// recording a permanent failure against a node that applied fine.
var ex = await Should.ThrowAsync<RpcException>(() =>
Service(new ActorRegistry()).ReportApply(WellFormed(), new FakeServerCallContext()));
ex.StatusCode.ShouldBe(StatusCode.Unavailable);
}
[Fact]
public async Task An_unspecified_outcome_yields_InvalidArgument()
{
// proto3 decodes an absent enum as 0. Refusing is the whole point: the deploy path seals on
// Applied, so guessing an outcome the node never sent would seal a deployment nobody applied.
var probe = CreateTestProbe("central-comm");
var registry = new ActorRegistry();
registry.Register<CentralCommunicationActorKey>(probe.Ref);
var request = WellFormed();
request.Outcome = ApplyOutcome.Unspecified;
var ex = await Should.ThrowAsync<RpcException>(() =>
Service(registry).ReportApply(request, new FakeServerCallContext()));
ex.StatusCode.ShouldBe(StatusCode.InvalidArgument);
probe.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
}
[Fact]
public async Task A_malformed_id_yields_InvalidArgument()
{
var request = WellFormed();
request.DeploymentId = "not-a-guid";
var probe = CreateTestProbe("central-comm");
var registry = new ActorRegistry();
registry.Register<CentralCommunicationActorKey>(probe.Ref);
var ex = await Should.ThrowAsync<RpcException>(() =>
Service(registry).ReportApply(request, new FakeServerCallContext()));
ex.StatusCode.ShouldBe(StatusCode.InvalidArgument);
probe.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
}
[Fact]
public async Task A_blank_node_id_yields_InvalidArgument()
{
// NodeId.Parse refuses a blank name; an ack whose node cannot be named is uncountable at the
// coordinator, so it must be refused here rather than forwarded as an anonymous ack.
var request = WellFormed();
request.NodeId = "";
var probe = CreateTestProbe("central-comm");
var registry = new ActorRegistry();
registry.Register<CentralCommunicationActorKey>(probe.Ref);
var ex = await Should.ThrowAsync<RpcException>(() =>
Service(registry).ReportApply(request, new FakeServerCallContext()));
ex.StatusCode.ShouldBe(StatusCode.InvalidArgument);
probe.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
}
[Fact]
public async Task An_undecodable_message_is_graded_before_the_registry()
{
// Ordering pin. With NO actor registered AND an undecodable message, the answer is
// InvalidArgument, not Unavailable: Unavailable invites a retry, and retrying a message that
// can never decode is a loop. The caller's fault is reported as the caller's fault.
var request = WellFormed();
request.Outcome = ApplyOutcome.Unspecified;
var ex = await Should.ThrowAsync<RpcException>(() =>
Service(new ActorRegistry()).ReportApply(request, new FakeServerCallContext()));
ex.StatusCode.ShouldBe(StatusCode.InvalidArgument);
}
[Fact]
public async Task A_decode_failure_logs_the_raw_correlation_id()
{
// The correlation id is logged as the raw wire string precisely because the ids may be what
// failed to parse — an operator correlating a rejection cannot be given a value that only
// exists once decoding succeeded.
var logger = new CapturingLogger<CentralCommandGrpcService>();
var request = WellFormed();
request.CorrelationId = "correlation-that-never-parses";
request.DeploymentId = "not-a-guid";
await Should.ThrowAsync<RpcException>(() =>
new CentralCommandGrpcService(new ActorRegistry(), logger)
.ReportApply(request, new FakeServerCallContext()));
logger.Entries.Any(e => e.Message.Contains("correlation-that-never-parses", StringComparison.Ordinal))
.ShouldBeTrue("the rejection log must carry the raw correlation id");
}
[Fact]
public async Task A_not_ready_rejection_logs_the_peer_and_the_correlation_id()
{
// Diagnostic symmetry with the decode-failure warning above. The two rejections are the only
// two ways an ack fails to reach the coordinator, and an operator reading the central log has
// to correlate BOTH against the node's own log — which needs the correlation id — and against
// which node was calling, which needs the peer. Decoding has already succeeded on this path,
// so the decoded correlation id costs nothing to include.
var logger = new CapturingLogger<CentralCommandGrpcService>();
var request = WellFormed();
await Should.ThrowAsync<RpcException>(() =>
new CentralCommandGrpcService(new ActorRegistry(), logger)
.ReportApply(request, new FakeServerCallContext()));
var warning = logger.Entries.Single(e => e.Level == LogLevel.Warning);
warning.Message.ShouldContain("ipv4:127.0.0.1:0");
warning.Message.ShouldContain(request.CorrelationId);
}
/// <summary>Test logger that captures all log entries.</summary>
/// <typeparam name="T">The category type.</typeparam>
private sealed class CapturingLogger<T> : ILogger<T>
{
/// <summary>Gets the captured log entries.</summary>
public List<(LogLevel Level, string Message, Exception? Exception)> Entries { get; } = [];
/// <summary>Begins a scope (stub implementation).</summary>
/// <typeparam name="TState">The type of the scope state.</typeparam>
/// <param name="state">The scope state.</param>
/// <returns>A no-op scope.</returns>
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
/// <summary>Checks if logging is enabled (always true).</summary>
/// <param name="logLevel">The log level to check.</param>
/// <returns><c>true</c>.</returns>
public bool IsEnabled(LogLevel logLevel) => true;
/// <summary>Logs an entry and captures it.</summary>
/// <typeparam name="TState">The type of the log state.</typeparam>
/// <param name="logLevel">The log level.</param>
/// <param name="eventId">The event ID.</param>
/// <param name="state">The log state.</param>
/// <param name="exception">The exception, if any.</param>
/// <param name="formatter">The log message formatter.</param>
public void Log<TState>(
LogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func<TState, Exception?, string> formatter)
{
Entries.Add((logLevel, formatter(state, exception), exception));
}
private sealed class NullScope : IDisposable
{
/// <summary>Gets the singleton instance.</summary>
public static NullScope Instance { get; } = new();
/// <summary>No-op.</summary>
public void Dispose() { }
}
}
/// <summary>Minimal <see cref="ServerCallContext"/> — the service reads only the method path.</summary>
private sealed class FakeServerCallContext : ServerCallContext
{
protected override string MethodCore => "/mesh_transport.v1.CentralCommandService/ReportApply";
protected override string HostCore => "localhost";
protected override string PeerCore => "ipv4:127.0.0.1:0";
protected override DateTime DeadlineCore => DateTime.MaxValue;
protected override Metadata RequestHeadersCore => [];
protected override CancellationToken CancellationTokenCore => CancellationToken.None;
protected override Metadata ResponseTrailersCore { get; } = [];
protected override Status StatusCore { get; set; }
protected override WriteOptions? WriteOptionsCore { get; set; }
protected override AuthContext AuthContextCore => new(null, new Dictionary<string, List<AuthProperty>>());
protected override ContextPropagationToken CreatePropagationTokenCore(ContextPropagationOptions? options) =>
throw new NotSupportedException();
protected override Task WriteResponseHeadersAsyncCore(Metadata responseHeaders) => Task.CompletedTask;
}
}