Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/CentralControlGrpcServiceAuditIngestTests.cs
T

195 lines
7.2 KiB
C#

using Akka.Actor;
using Akka.TestKit.Xunit2;
using Grpc.Core;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Communication.Actors;
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
using Microsoft.Extensions.DependencyInjection;
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests.Grpc;
/// <summary>
/// Audit Log (#23) site→central ingest routing on <see cref="CentralControlGrpcService"/>.
/// <para>
/// The service Asks the <c>audit-log-ingest</c> singleton proxy DIRECTLY. It used to relay
/// through <see cref="CentralCommunicationActor"/>, which re-Asked the same proxy with the same
/// 30 s <see cref="SiteStreamGrpcServer.AuditIngestAskTimeout"/> — a second hop whose inner Ask
/// expired at the same instant as the outer one, so it could only add latency. These tests pin
/// the direct dispatch, the wiring-race reply, and the fact that the relay is gone.
/// </para>
/// </summary>
public class CentralControlGrpcServiceAuditIngestTests : TestKit
{
private static ServerCallContext NewContext(CancellationToken ct = default)
{
var context = Substitute.For<ServerCallContext>();
context.CancellationToken.Returns(ct);
return context;
}
private static CentralControlGrpcService CreateService() => new(
NullLogger<CentralControlGrpcService>.Instance,
Options.Create(new CommunicationOptions()));
[Fact]
public async Task IngestAuditEvents_AsksTheIngestProxy_AndNeverTheCommunicationActor()
{
var ingest = CreateTestProbe();
var control = CreateTestProbe();
var service = CreateService();
service.SetReady(control.Ref);
service.SetAuditIngestActor(ingest.Ref);
var evt = SampleAuditEvent();
var batch = new AuditEventBatch();
batch.Events.Add(AuditEventDtoMapper.ToDto(evt));
var call = service.IngestAuditEvents(batch, NewContext());
var received = ingest.ExpectMsg<IngestAuditEventsCommand>();
Assert.Equal(evt.EventId, Assert.Single(received.Events).EventId);
ingest.Reply(new IngestAuditEventsReply(new[] { evt.EventId }));
var ack = await call;
Assert.Equal(evt.EventId.ToString(), Assert.Single(ack.AcceptedEventIds));
// The old relay hop is gone: the control-plane actor sees nothing at all.
control.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
}
[Fact]
public async Task IngestCachedTelemetry_AsksTheIngestProxy_AndNeverTheCommunicationActor()
{
var ingest = CreateTestProbe();
var control = CreateTestProbe();
var service = CreateService();
service.SetReady(control.Ref);
service.SetAuditIngestActor(ingest.Ref);
var evt = SampleAuditEvent();
var batch = new CachedTelemetryBatch();
batch.Packets.Add(new CachedTelemetryPacket
{
AuditEvent = AuditEventDtoMapper.ToDto(evt),
Operational = SiteCallDtoMapper.ToDto(SampleSiteCall()),
});
var call = service.IngestCachedTelemetry(batch, NewContext());
var received = ingest.ExpectMsg<IngestCachedTelemetryCommand>();
Assert.Single(received.Entries);
ingest.Reply(new IngestCachedTelemetryReply(new[] { evt.EventId }));
var ack = await call;
Assert.Equal(evt.EventId.ToString(), Assert.Single(ack.AcceptedEventIds));
control.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
}
[Fact]
public async Task IngestAuditEvents_BeforeTheIngestProxyIsWired_ReturnsAnEmptyAck()
{
// The singleton starts moments after SetReady, so this window is real. An empty ack
// (NOT a fault, NOT Unavailable) leaves the site's rows Pending for the next drain —
// byte-for-byte what the removed relay replied when its proxy was still null.
var service = CreateService();
service.SetReady(CreateTestProbe().Ref);
var batch = new AuditEventBatch();
batch.Events.Add(AuditEventDtoMapper.ToDto(SampleAuditEvent()));
var ack = await service.IngestAuditEvents(batch, NewContext());
Assert.Empty(ack.AcceptedEventIds);
}
[Fact]
public async Task IngestCachedTelemetry_BeforeTheIngestProxyIsWired_ReturnsAnEmptyAck()
{
var service = CreateService();
service.SetReady(CreateTestProbe().Ref);
var batch = new CachedTelemetryBatch();
batch.Packets.Add(new CachedTelemetryPacket
{
AuditEvent = AuditEventDtoMapper.ToDto(SampleAuditEvent()),
Operational = SiteCallDtoMapper.ToDto(SampleSiteCall()),
});
var ack = await service.IngestCachedTelemetry(batch, NewContext());
Assert.Empty(ack.AcceptedEventIds);
}
[Fact]
public void SetAuditIngestActor_IsIndependentOfSetReady()
{
var service = CreateService();
Assert.False(service.IsAuditIngestBound);
service.SetAuditIngestActor(CreateTestProbe().Ref);
Assert.True(service.IsAuditIngestBound);
Assert.False(service.IsReady);
}
[Fact]
public void CentralCommunicationActor_NoLongerRelaysIngestCommands()
{
// Regression pin for the removed hop: the actor has no ingest receive at all, so an
// ingest command reaching it is an unhandled message with no reply — not a silent
// second path that could drift from the direct one.
var actor = CreateCentralCommunicationActor();
actor.Tell(new IngestAuditEventsCommand(new[] { SampleAuditEvent() }), TestActor);
actor.Tell(
new IngestCachedTelemetryCommand(
new[] { new CachedTelemetryEntry(SampleAuditEvent(), SampleSiteCall()) }),
TestActor);
ExpectNoMsg(TimeSpan.FromMilliseconds(300));
}
private IActorRef CreateCentralCommunicationActor()
{
var siteRepo = Substitute.For<ISiteRepository>();
siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
.Returns(new List<Commons.Entities.Sites.Site>());
var services = new ServiceCollection();
services.AddScoped(_ => siteRepo);
var sp = services.BuildServiceProvider();
var transport = Substitute.For<ISiteCommandTransport>();
return Sys.ActorOf(Props.Create(() => new CentralCommunicationActor(sp, transport)));
}
private static AuditEvent SampleAuditEvent() =>
ScadaBridgeAuditEventFactory.Create(
channel: AuditChannel.ApiOutbound,
kind: AuditKind.ApiCall,
status: AuditStatus.Delivered,
sourceSiteId: "site-a");
private static SiteCall SampleSiteCall() => new()
{
TrackedOperationId = TrackedOperationId.New(),
Channel = "OutboundApi",
Target = "ExternalSystemA",
SourceSite = "site-a",
Status = "Delivered",
RetryCount = 0,
CreatedAtUtc = DateTime.UtcNow,
UpdatedAtUtc = DateTime.UtcNow,
IngestedAtUtc = DateTime.UtcNow,
};
}