feat(grpc): host CentralControlService on the central node (T1A.2)

Central now ALSO listens for the seven site→central control messages over
gRPC, alongside the existing ClusterClient path. Nothing flips to gRPC yet —
sites keep CentralTransport=Akka (T1A.3's job); central simply starts also
accepting.

- CentralControlGrpcService (Communication.Grpc): decodes each RPC onto the
  SAME in-process message the ClusterClient path carries, Asks the existing
  CentralCommunicationActor (zero handler-logic changes), encodes the reply via
  the T1A.1 mapper. Readiness-gated like SiteStreamGrpcServer.SetReady —
  Unavailable until AkkaHostedService hands the actor over. Heartbeat stays
  fire-and-forget (Tell, always-OK, never gated on readiness). Ingest reuses the
  shared SiteStreamGrpcServer.AuditIngestAskTimeout constant. Fault→status
  mapping is retry-aware: Unavailable (never dispatched, safe to cross-node
  retry) vs DeadlineExceeded/Internal (it ran, do not re-send elsewhere).

- CentralControlAuthInterceptor (Host): a SEPARATE interceptor class, not a
  variant constructor on ControlPlaneAuthInterceptor. Central's model is per-site
  (verify the Bearer token against the key for the site in the required
  x-scadabridge-site header, via ISitePskProvider) where a site verifies its one
  own-key — a genuinely different model. Fail-closed on every branch: missing or
  blank header, unresolvable key, and mismatched token all → PermissionDenied,
  never pass-through. One public constructor only (the explicit-prefix ctor is
  internal), pinned by a reflection test — a second public ctor makes
  Grpc.AspNetCore's GetFactory() throw per-call and silently disables the gate.

- Explicit Kestrel h2c listener on new option ScadaBridge:Node:CentralGrpcPort
  (default 8083, symmetric with sites), mirroring the Site branch. Additive to
  central's :5000 HTTP/1 surface, which is untouched — gRPC does NOT go through
  Traefik (HTTP/1 only). Registered by type on AddGrpc; service mapped with
  MapGrpcService. Port range-validated by NodeOptionsValidator.

- Rig: publish the central gRPC port 9013:8083 / 9014:8083 on both central nodes
  so a later task can exercise it.

Tests: CentralControlEndToEndTests (Host.Tests, TestServer + real interceptor +
real service over a stub actor) proves auth positives/negatives are
distinguishable and covers unary + the ingest bridge shapes; the interceptor is
registered BY TYPE, never in DI. CentralControlAuthInterceptorTests pins the
per-site gate + one-public-ctor invariant. CentralControlGrpcServiceTests
(Communication.Tests, TestKit) covers the readiness gate, fire-and-forget
heartbeat, and the DeadlineExceeded-vs-Unavailable status mapping. No active
<Protobuf> item. Communication.Tests (356) + Host.Tests (384) green.
This commit is contained in:
Joseph Doherty
2026-07-22 18:53:59 -04:00
parent d7455577a8
commit 780bb9c369
10 changed files with 1245 additions and 0 deletions
@@ -0,0 +1,181 @@
using Akka.Actor;
using Akka.TestKit;
using Akka.TestKit.Xunit2;
using Grpc.Core;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification;
using ZB.MOM.WW.ScadaBridge.Communication;
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests.Grpc;
/// <summary>
/// Behaviour of <see cref="CentralControlGrpcService"/> that does not need a real gRPC pipeline:
/// the readiness gate, the fire-and-forget heartbeat contract, the ingest-timeout status
/// mapping, and the shared audit-ingest timeout constant. The auth interceptor + real transport
/// are proven separately in <c>CentralControlEndToEndTests</c> (Host.Tests).
/// </summary>
public class CentralControlGrpcServiceTests : TestKit
{
private static ServerCallContext NewContext(CancellationToken ct = default)
{
var context = Substitute.For<ServerCallContext>();
context.CancellationToken.Returns(ct);
return context;
}
private CentralControlGrpcService CreateService(CommunicationOptions? options = null)
=> new(
NullLogger<CentralControlGrpcService>.Instance,
Options.Create(options ?? new CommunicationOptions()));
[Fact]
public async Task BeforeSetReady_AUnaryCall_IsUnavailable()
{
// Nothing was dispatched, so Unavailable is the right status — it tells a site transport
// the call never ran and a cross-node retry is safe.
var service = CreateService();
var ex = await Assert.ThrowsAsync<RpcException>(
() => service.SubmitNotification(new NotificationSubmitDto(), NewContext()));
Assert.Equal(StatusCode.Unavailable, ex.StatusCode);
}
[Fact]
public async Task BeforeSetReady_ANonEmptyIngestBatch_IsUnavailable()
{
var service = CreateService();
var batch = new AuditEventBatch();
batch.Events.Add(NewAuditDto());
var ex = await Assert.ThrowsAsync<RpcException>(
() => service.IngestAuditEvents(batch, NewContext()));
Assert.Equal(StatusCode.Unavailable, ex.StatusCode);
}
[Fact]
public async Task AfterSetReady_AUnaryCall_ReachesTheActor()
{
var stub = Sys.ActorOf(Props.Create(() => new StubActor()));
var service = CreateService();
service.SetReady(stub);
var ack = await service.SubmitNotification(NewNotificationDto(), NewContext());
Assert.True(ack.Accepted);
}
[Fact]
public async Task Heartbeat_BeforeSetReady_SucceedsAndIsDropped()
{
// Fire-and-forget end-to-end: a heartbeat must never fault the site's timer, so even
// with no actor wired the call returns OK rather than Unavailable.
var service = CreateService();
var reply = await service.Heartbeat(NewHeartbeatDto(), NewContext());
Assert.NotNull(reply);
}
[Fact]
public async Task Heartbeat_AfterSetReady_TellsTheActor_AndNeverAsks()
{
// A black-hole actor that never replies would hang an Ask forever; the heartbeat still
// returns immediately, proving it is a Tell, not an Ask.
var blackHole = Sys.ActorOf(Props.Create(() => new NeverRepliesActor()));
var service = CreateService();
service.SetReady(blackHole);
var reply = await service.Heartbeat(NewHeartbeatDto(), NewContext());
Assert.NotNull(reply);
}
[Fact]
public async Task WhenTheActorNeverReplies_TheCall_IsDeadlineExceeded_NotUnavailable()
{
// The message WAS delivered (Unavailable would wrongly invite a duplicate retry on the
// peer node); a timeout is DeadlineExceeded, which callers never cross-node-retry.
var blackHole = Sys.ActorOf(Props.Create(() => new NeverRepliesActor()));
var service = CreateService(new CommunicationOptions
{
NotificationForwardTimeout = TimeSpan.FromMilliseconds(200),
});
service.SetReady(blackHole);
var ex = await Assert.ThrowsAsync<RpcException>(
() => service.SubmitNotification(NewNotificationDto(), NewContext()));
Assert.Equal(StatusCode.DeadlineExceeded, ex.StatusCode);
}
[Fact]
public async Task EmptyIngestBatch_ShortCircuits_EvenBeforeSetReady()
{
// An empty batch is a no-op the actor need never see; it must not depend on readiness.
var service = CreateService();
var ack = await service.IngestAuditEvents(new AuditEventBatch(), NewContext());
Assert.Empty(ack.AcceptedEventIds);
}
[Fact]
public void TheAuditIngestTimeout_IsTheOneSharedConstant()
{
// The plan calls SiteStreamGrpcServer.AuditIngestAskTimeout "one source of truth" shared
// between the two audit-ingest transports; the central service must not re-declare 30s.
Assert.Equal(TimeSpan.FromSeconds(30), SiteStreamGrpcServer.AuditIngestAskTimeout);
}
// The mapper reads SiteEnqueuedAt unconditionally, so a DTO that reaches mapping must carry
// a timestamp. Only DTOs that get past the readiness/auth gate map, so the negative tests
// above can pass a bare DTO.
private static NotificationSubmitDto NewNotificationDto() => new()
{
NotificationId = Guid.NewGuid().ToString(),
ListName = "ops",
SiteEnqueuedAt = Google.Protobuf.WellKnownTypes.Timestamp.FromDateTime(
DateTime.SpecifyKind(new DateTime(2026, 5, 20, 10, 0, 0), DateTimeKind.Utc)),
};
private static HeartbeatDto NewHeartbeatDto() => new()
{
SiteId = "site-a",
NodeHostname = "node-a",
IsActive = true,
Timestamp = Google.Protobuf.WellKnownTypes.Timestamp.FromDateTime(
DateTime.SpecifyKind(new DateTime(2026, 5, 20, 10, 0, 0), DateTimeKind.Utc)),
};
private static AuditEventDto NewAuditDto() => new()
{
EventId = Guid.NewGuid().ToString(),
OccurredAtUtc = Google.Protobuf.WellKnownTypes.Timestamp.FromDateTime(
DateTime.SpecifyKind(new DateTime(2026, 5, 20, 10, 0, 0), DateTimeKind.Utc)),
Channel = "ApiOutbound",
Kind = "ApiCall",
Status = "Delivered",
SourceSiteId = "site-a",
};
private sealed class StubActor : ReceiveActor
{
public StubActor()
{
Receive<NotificationSubmit>(msg =>
Sender.Tell(new NotificationSubmitAck(msg.NotificationId, Accepted: true, Error: null)));
}
}
/// <summary>Swallows every message and never replies, so an Ask against it times out.</summary>
private sealed class NeverRepliesActor : ReceiveActor
{
public NeverRepliesActor() => ReceiveAny(_ => { });
}
}
@@ -0,0 +1,197 @@
using Grpc.Core;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
/// <summary>
/// Central's inbound gate for the site→central gRPC control plane (T1A.2). The mirror of
/// <see cref="ControlPlaneAuthInterceptorTests"/>, but central's verification model is inverted:
/// it looks up the key for the site named in the <c>x-scadabridge-site</c> header and checks the
/// bearer token against THAT, rather than against one own-key.
/// </summary>
/// <remarks>
/// Central genuinely needs a distinct verification model, so it is a separate class with its own
/// single public constructor — not a variant ctor on <see cref="ControlPlaneAuthInterceptor"/>,
/// whose two-public-constructor form once silently disabled the gate. The one-public-ctor
/// invariant is pinned below exactly as the sibling pins it.
/// </remarks>
public class CentralControlAuthInterceptorTests
{
private const string ControlMethod =
"/scadabridge.centralcontrol.v1.CentralControlService/SubmitNotification";
private const string SiteStreamMethod = "/sitestream.SiteStreamService/SubscribeInstance";
private const string SiteA = "site-a";
private const string SiteAKey = "site-a-preshared-key";
private const string SiteB = "site-b";
private const string SiteBKey = "site-b-preshared-key";
/// <summary>An <see cref="ISitePskProvider"/> backed by a fixed site→key map; throws for unknown sites.</summary>
private sealed class MapPskProvider(IReadOnlyDictionary<string, string> keys) : ISitePskProvider
{
public ValueTask<string> GetAsync(string siteId, CancellationToken ct)
=> keys.TryGetValue(siteId, out var key)
? new ValueTask<string>(key)
: throw new InvalidOperationException($"no key for '{siteId}'");
public void Invalidate(string siteId) { }
}
private static CentralControlAuthInterceptor CreateInterceptor()
=> new(
new MapPskProvider(new Dictionary<string, string> { [SiteA] = SiteAKey, [SiteB] = SiteBKey }),
NullLogger<CentralControlAuthInterceptor>.Instance);
private static ServerCallContext CreateContext(
string method, string? siteHeader, string? authorizationHeader)
{
var headers = new Metadata();
if (siteHeader is not null)
headers.Add(ControlPlaneCredentials.SiteHeader, siteHeader);
if (authorizationHeader is not null)
headers.Add(ControlPlaneCredentials.AuthorizationHeader, authorizationHeader);
return new FakeServerCallContext(method, headers);
}
private sealed class FakeServerCallContext(string method, Metadata requestHeaders)
: ServerCallContext
{
protected override string MethodCore => method;
protected override string HostCore => "localhost";
protected override string PeerCore => "ipv4:127.0.0.1:12345";
protected override DateTime DeadlineCore => DateTime.UtcNow.AddMinutes(1);
protected override Metadata RequestHeadersCore => requestHeaders;
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 { get; } =
new(null, new Dictionary<string, List<AuthProperty>>());
protected override ContextPropagationToken CreatePropagationTokenCore(
ContextPropagationOptions? options)
=> throw new NotSupportedException();
protected override Task WriteResponseHeadersAsyncCore(Metadata responseHeaders)
=> Task.CompletedTask;
}
private static Task<string> Invoke(
CentralControlAuthInterceptor interceptor, ServerCallContext context)
=> interceptor.UnaryServerHandler<string, string>(
"request", context, (_, _) => Task.FromResult("ok"));
[Fact]
public async Task NonGatedMethod_PassesThrough()
{
// Central's interceptor gates only CentralControlService; anything else on the listener
// (there is nothing today) is not its concern.
var interceptor = CreateInterceptor();
var context = CreateContext(SiteStreamMethod, siteHeader: null, authorizationHeader: null);
Assert.Equal("ok", await Invoke(interceptor, context));
}
[Fact]
public async Task CorrectSiteAndKey_IsAccepted()
{
var interceptor = CreateInterceptor();
var context = CreateContext(ControlMethod, SiteA, $"Bearer {SiteAKey}");
Assert.Equal("ok", await Invoke(interceptor, context));
}
[Fact]
public async Task MissingSiteHeader_IsDenied()
{
// Fail-closed: no header means no per-site key to verify against, so there is nothing to
// pass through TO. A present bearer token does not rescue it.
var interceptor = CreateInterceptor();
var context = CreateContext(ControlMethod, siteHeader: null, $"Bearer {SiteAKey}");
var ex = await Assert.ThrowsAsync<RpcException>(() => Invoke(interceptor, context));
Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode);
}
[Fact]
public async Task BlankSiteHeader_IsDenied()
{
var interceptor = CreateInterceptor();
var context = CreateContext(ControlMethod, siteHeader: " ", $"Bearer {SiteAKey}");
var ex = await Assert.ThrowsAsync<RpcException>(() => Invoke(interceptor, context));
Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode);
}
[Fact]
public async Task UnknownSite_IsDenied_NotPassedThrough()
{
// The provider throws for an unknown site; the interceptor must turn that into a denial,
// never swallow it and let the call proceed unauthenticated.
var interceptor = CreateInterceptor();
var context = CreateContext(ControlMethod, "site-nonexistent", "Bearer whatever");
var ex = await Assert.ThrowsAsync<RpcException>(() => Invoke(interceptor, context));
Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode);
}
[Fact]
public async Task NoBearerToken_IsDenied()
{
var interceptor = CreateInterceptor();
var context = CreateContext(ControlMethod, SiteA, authorizationHeader: null);
var ex = await Assert.ThrowsAsync<RpcException>(() => Invoke(interceptor, context));
Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode);
}
[Fact]
public async Task WrongKeyForTheSite_IsDenied()
{
// site-a presents site-b's key. Both keys are valid keys; the point is the token must
// match the key for THIS site, not just be a key central knows.
var interceptor = CreateInterceptor();
var context = CreateContext(ControlMethod, SiteA, $"Bearer {SiteBKey}");
var ex = await Assert.ThrowsAsync<RpcException>(() => Invoke(interceptor, context));
Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode);
}
[Fact]
public async Task EachSiteIsVerifiedAgainstItsOwnKey()
{
// site-b with site-b's key is accepted by the same interceptor instance that rejected
// site-a-with-site-b's-key above — proving the per-site lookup, not a single shared key.
var interceptor = CreateInterceptor();
var context = CreateContext(ControlMethod, SiteB, $"Bearer {SiteBKey}");
Assert.Equal("ok", await Invoke(interceptor, context));
}
[Fact]
public void TheInterceptorHasExactlyOnePublicConstructor()
{
// Grpc.AspNetCore registers this interceptor BY TYPE; InterceptorRegistration.GetFactory()
// throws "Multiple constructors accepting all given argument types have been found" the
// moment a second public constructor is applicable, and the throw lands inside the
// pipeline on every call — a gate that authorizes nothing while looking like a handler
// bug. The explicit-prefix constructor is internal to keep it from recurring; this pins
// that. (Same invariant as ControlPlaneAuthInterceptorTests.)
var publicCtors = typeof(CentralControlAuthInterceptor).GetConstructors();
Assert.Single(publicCtors);
}
[Fact]
public void DefaultGatedPrefixes_MatchTheRealCentralControlServicePath()
{
// A typo here disables the whole gate silently: every call would pass through. Pin it
// against the generated service descriptor, not the proto text.
var method = CentralControlService.Descriptor.FullName;
Assert.Contains(
CentralControlAuthInterceptor.DefaultGatedPrefixes,
p => p == $"/{method}/");
}
}
@@ -0,0 +1,256 @@
using Akka.Actor;
using Google.Protobuf.WellKnownTypes;
using Grpc.Core;
using Grpc.Net.Client;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification;
using ZB.MOM.WW.ScadaBridge.Communication;
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
/// <summary>
/// End-to-end proof of the central-hosted gRPC control plane (T1A.2): the real
/// <see cref="CentralControlAuthInterceptor"/> AND the real
/// <see cref="CentralControlGrpcService"/> over a real gRPC stack, the service Asking a real
/// (stub) <c>CentralCommunicationActor</c> stand-in.
/// </summary>
/// <remarks>
/// <para>
/// The interceptor is registered <b>BY TYPE on <c>AddGrpc</c></b>, exactly as <c>Program.cs</c>
/// does, and is deliberately NOT placed in DI as a singleton — pre-registering it lets DI hand
/// the instance back and bypasses <c>Grpc.AspNetCore</c>'s own activation path, which is how the
/// two-public-constructor defect escaped a green suite once before. This harness copies the
/// shape of <see cref="ControlPlaneAuthEndToEndTests"/> for the same reason.
/// </para>
/// <para>
/// Runs in-process over <see cref="TestServer"/>: no ports, no containers. A tiny Akka actor
/// stands in for <c>CentralCommunicationActor</c> so the test never touches MSSQL or a cluster;
/// the method paths, message types and mapper are the real generated/production ones.
/// </para>
/// </remarks>
public class CentralControlEndToEndTests : IAsyncLifetime
{
private const string SiteA = "site-a";
private const string SiteAKey = "site-a-preshared-key";
private const string SiteB = "site-b";
private const string SiteBKey = "site-b-preshared-key";
// The deterministic id the stub actor "accepts" for every ingest batch, so the ingest
// bridge can be asserted without extracting ids out of a decoded AuditEvent.
private static readonly Guid AcceptedId = Guid.Parse("11111111-1111-1111-1111-111111111111");
private IHost _host = null!;
private TestServer _server = null!;
private ActorSystem _actorSystem = null!;
private CentralControlGrpcService _service = null!;
/// <inheritdoc />
public async Task InitializeAsync()
{
_actorSystem = ActorSystem.Create("centralcontrol-e2e-test");
var stub = _actorSystem.ActorOf(Props.Create(() => new StubCentralActor(AcceptedId)), "central-stub");
_service = new CentralControlGrpcService(
NullLogger<CentralControlGrpcService>.Instance,
Options.Create(new CommunicationOptions()));
_service.SetReady(stub);
var pskProvider = new MapPskProvider(new Dictionary<string, string>
{
[SiteA] = SiteAKey,
[SiteB] = SiteBKey,
});
_host = await new HostBuilder()
.ConfigureWebHost(web => web
.UseTestServer()
.ConfigureServices(services =>
{
// BY TYPE, and NOT also in DI — see the class remarks.
services.AddGrpc(o => o.Interceptors.Add<CentralControlAuthInterceptor>());
services.AddSingleton<ISitePskProvider>(pskProvider);
services.AddSingleton(_service);
})
.Configure(app =>
{
app.UseRouting();
app.UseEndpoints(e => e.MapGrpcService<CentralControlGrpcService>());
}))
.StartAsync();
_server = _host.GetTestServer();
}
/// <inheritdoc />
public async Task DisposeAsync()
{
await _host.StopAsync();
_host.Dispose();
await _actorSystem.Terminate();
}
/// <summary>Builds a channel credentialed for <paramref name="siteId"/> with <paramref name="key"/>.</summary>
private GrpcChannel Channel(string? key, string siteId)
{
var options = new GrpcChannelOptions { HttpHandler = _server.CreateHandler() };
if (key is not null)
{
options.WithSiteCredentials(new FixedPskProvider(key), siteId);
}
return GrpcChannel.ForAddress(_server.BaseAddress, options);
}
private CentralControlService.CentralControlServiceClient Client(string? key, string siteId)
=> new(Channel(key, siteId));
// ---- Auth positives / negatives, all through the real pipeline ----
[Fact]
public async Task CorrectSiteAndKey_ReachesTheService_OnAUnaryCall()
{
var client = Client(SiteAKey, SiteA);
var ack = await client.SubmitNotificationAsync(NewNotificationDto());
Assert.True(ack.Accepted);
}
[Fact]
public async Task NoCredentialsAtAll_IsRejected_WithPermissionDenied()
{
var client = Client(key: null, SiteA);
var ex = await Assert.ThrowsAsync<RpcException>(
async () => await client.SubmitNotificationAsync(new NotificationSubmitDto()));
Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode);
}
[Fact]
public async Task WrongKeyForTheSite_IsRejected_WithPermissionDenied()
{
// site-a presents site-b's (valid, but wrong-for-this-site) key.
var client = Client(SiteBKey, SiteA);
var ex = await Assert.ThrowsAsync<RpcException>(
async () => await client.SubmitNotificationAsync(new NotificationSubmitDto()));
Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode);
}
[Fact]
public async Task UnknownSite_IsRejected_WithPermissionDenied()
{
var client = Client("any-key", "site-nonexistent");
var ex = await Assert.ThrowsAsync<RpcException>(
async () => await client.SubmitNotificationAsync(new NotificationSubmitDto()));
Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode);
}
[Fact]
public async Task TheAcceptAndRejectPathsAreDistinguishable()
{
// A gate whose accept and reject paths produce the same observable result is not a gate.
// Correct key → the service answers (Accepted:true); wrong key → PermissionDenied,
// never reaching the service. These are two different outcomes, which is the whole point.
var ok = await Client(SiteAKey, SiteA).SubmitNotificationAsync(NewNotificationDto());
Assert.True(ok.Accepted);
var ex = await Assert.ThrowsAsync<RpcException>(
async () => await Client("wrong", SiteA).SubmitNotificationAsync(new NotificationSubmitDto()));
Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode);
}
// ---- One RPC per shape: unary (above) + the ingest bridge ----
[Fact]
public async Task IngestAuditEvents_DecodesTheBatch_AsksTheActor_EncodesTheAck()
{
var client = Client(SiteAKey, SiteA);
var batch = new AuditEventBatch();
batch.Events.Add(NewAuditDto());
batch.Events.Add(NewAuditDto());
var ack = await client.IngestAuditEventsAsync(batch);
// The stub actor accepts one deterministic id per non-empty batch; its presence proves
// the full DTO→command→Ask→reply→ack bridge ran, gated call and all.
Assert.Contains(AcceptedId.ToString(), ack.AcceptedEventIds);
}
[Fact]
public async Task IngestAuditEvents_EmptyBatch_ShortCircuits_WithoutAskingTheActor()
{
// Even the empty-batch fast path is behind the gate — it still needs a valid key.
var client = Client(SiteAKey, SiteA);
var ack = await client.IngestAuditEventsAsync(new AuditEventBatch());
Assert.Empty(ack.AcceptedEventIds);
}
// The mapper reads SiteEnqueuedAt unconditionally; only DTOs that clear the gate reach it,
// so the accept-path tests carry a timestamp while the negative tests can pass a bare DTO.
private static NotificationSubmitDto NewNotificationDto() => new()
{
NotificationId = Guid.NewGuid().ToString(),
ListName = "ops",
SiteEnqueuedAt = Timestamp.FromDateTime(
DateTime.SpecifyKind(new DateTime(2026, 5, 20, 10, 0, 0), DateTimeKind.Utc)),
};
private static AuditEventDto NewAuditDto() => new()
{
EventId = Guid.NewGuid().ToString(),
OccurredAtUtc = Timestamp.FromDateTime(
DateTime.SpecifyKind(new DateTime(2026, 5, 20, 10, 0, 0), DateTimeKind.Utc)),
Channel = "ApiOutbound",
Kind = "ApiCall",
Status = "Delivered",
SourceSiteId = SiteA,
};
private sealed class FixedPskProvider(string key) : ISitePskProvider
{
public ValueTask<string> GetAsync(string siteId, CancellationToken ct) => new(key);
public void Invalidate(string siteId) { }
}
private sealed class MapPskProvider(IReadOnlyDictionary<string, string> keys) : ISitePskProvider
{
public ValueTask<string> GetAsync(string siteId, CancellationToken ct)
=> keys.TryGetValue(siteId, out var key)
? new ValueTask<string>(key)
: throw new InvalidOperationException($"no key for '{siteId}'");
public void Invalidate(string siteId) { }
}
/// <summary>
/// Minimal stand-in for <c>CentralCommunicationActor</c>: answers the two RPC shapes this
/// test exercises. Replies straight to the Ask's temp sender, exactly as the real actor's
/// Forward/PipeTo paths do.
/// </summary>
private sealed class StubCentralActor : ReceiveActor
{
public StubCentralActor(Guid acceptedId)
{
Receive<NotificationSubmit>(msg =>
Sender.Tell(new NotificationSubmitAck(msg.NotificationId, Accepted: true, Error: null)));
Receive<IngestAuditEventsCommand>(_ =>
Sender.Tell(new IngestAuditEventsReply(new[] { acceptedId })));
}
}
}