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;
///
/// End-to-end proof of the central-hosted gRPC control plane (T1A.2): the real
/// AND the real
/// over a real gRPC stack, the service Asking a real
/// (stub) CentralCommunicationActor stand-in.
///
///
///
/// The interceptor is registered BY TYPE on AddGrpc, exactly as Program.cs
/// does, and is deliberately NOT placed in DI as a singleton — pre-registering it lets DI hand
/// the instance back and bypasses Grpc.AspNetCore's own activation path, which is how the
/// two-public-constructor defect escaped a green suite once before. This harness copies the
/// shape of for the same reason.
///
///
/// Runs in-process over : no ports, no containers. A tiny Akka actor
/// stands in for CentralCommunicationActor so the test never touches MSSQL or a cluster;
/// the method paths, message types and mapper are the real generated/production ones.
///
///
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!;
///
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.Instance,
Options.Create(new CommunicationOptions()));
_service.SetReady(stub);
var pskProvider = new MapPskProvider(new Dictionary
{
[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());
services.AddSingleton(pskProvider);
services.AddSingleton(_service);
})
.Configure(app =>
{
app.UseRouting();
app.UseEndpoints(e => e.MapGrpcService());
}))
.StartAsync();
_server = _host.GetTestServer();
}
///
public async Task DisposeAsync()
{
await _host.StopAsync();
_host.Dispose();
await _actorSystem.Terminate();
}
/// Builds a channel credentialed for with .
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(
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(
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(
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(
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 GetAsync(string siteId, CancellationToken ct) => new(key);
public void Invalidate(string siteId) { }
}
private sealed class MapPskProvider(IReadOnlyDictionary keys) : ISitePskProvider
{
public ValueTask GetAsync(string siteId, CancellationToken ct)
=> keys.TryGetValue(siteId, out var key)
? new ValueTask(key)
: throw new InvalidOperationException($"no key for '{siteId}'");
public void Invalidate(string siteId) { }
}
///
/// Minimal stand-in for CentralCommunicationActor: 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.
///
private sealed class StubCentralActor : ReceiveActor
{
public StubCentralActor(Guid acceptedId)
{
Receive(msg =>
Sender.Tell(new NotificationSubmitAck(msg.NotificationId, Accepted: true, Error: null)));
Receive(_ =>
Sender.Tell(new IngestAuditEventsReply(new[] { acceptedId })));
}
}
}