Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/CentralControlGrpcServiceAuditIngestTests.cs
T
Joseph Doherty 513fa1aa16 docs(tests): fix CommentChecker findings across tests/ — 13,392 → 0 (repo-wide scan clean)
Comment/XML-doc changes only:
- add summaries to ~10k test methods, fixtures, fakes and helpers
  (+ <param> on theories, <returns> on async Task methods,
  <inheritdoc/> on interface implementations the checker can resolve)
- strip ~2.6k project-tracking references (WP*, P*-*, row-NN, Task N,
  #NN, Component-0NN) from comments, keeping the explanatory text;
  reword false positives in comments only
- real docs on fakes implementing BCL/library interfaces
  (IDisposable, ILogger, IOptionsMonitor, IAsyncLifetime)
- reword the last 5 src/ false positives (CWE-1236, failures-1,
  year-0001, M365)

Only non-comment edits: four one-line nested classes split across lines
so members can carry docs. Build 0 errors / 0 warnings.
2026-10-07 15:05:49 -04:00

205 lines
8.3 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 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()));
/// <summary>Verifies that ingesting audit events asks the ingest proxy directly and never goes through the communication actor.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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));
}
/// <summary>Verifies that ingesting cached telemetry asks the ingest proxy directly and never goes through the communication actor.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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));
}
/// <summary>Verifies that ingesting audit events before the ingest proxy is wired returns an empty acknowledgement.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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);
}
/// <summary>Verifies that ingesting cached telemetry before the ingest proxy is wired returns an empty acknowledgement.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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);
}
/// <summary>Verifies that setting the audit ingest actor is independent of marking the service ready.</summary>
[Fact]
public void SetAuditIngestActor_IsIndependentOfSetReady()
{
var service = CreateService();
Assert.False(service.IsAuditIngestBound);
service.SetAuditIngestActor(CreateTestProbe().Ref);
Assert.True(service.IsAuditIngestBound);
Assert.False(service.IsReady);
}
/// <summary>Verifies that the central communication actor no longer relays ingest commands.</summary>
[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,
};
}