Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/SyntheticHeartbeatTests.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

216 lines
9.3 KiB
C#

using System.Buffers.Binary;
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Google.Protobuf.WellKnownTypes;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Health;
using ZB.MOM.WW.ScadaBridge.Communication.Actors;
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests;
/// <summary>
/// <see cref="CentralChannelProvider"/>'s failback probe reuses
/// the <c>Heartbeat</c> RPC to ask "does the preferred central endpoint answer again?". It is
/// emitted by a site's TRANSPORT layer, not by any node's heartbeat timer, so central must not
/// count it as liveness — otherwise a site whose real heartbeats had stopped keeps looking alive
/// on the health dashboard for as long as its transport keeps probing.
/// <para>
/// The contract is the explicit additive <c>Synthetic</c> flag (<c>HeartbeatDto.synthetic</c>,
/// proto field 5), NOT the <c>failback-probe</c> hostname, which is a log label only.
/// </para>
/// </summary>
public class SyntheticHeartbeatTests : TestKit
{
private static readonly DateTimeOffset T0 =
new(2026, 8, 14, 9, 0, 0, TimeSpan.Zero);
// ── The consumer: central skips liveness bookkeeping for a synthetic heartbeat ───
/// <summary>Verifies that a synthetic heartbeat does not mark the health aggregator.</summary>
[Fact]
public void SyntheticHeartbeat_DoesNotMarkTheHealthAggregator()
{
var (actor, aggregator) = CreateCentralActor();
actor.Tell(new HeartbeatMessage("site-1", CentralChannelProvider.SyntheticProbeHostname,
IsActive: false, Timestamp: T0, Synthetic: true));
// Give the actor a real chance to (wrongly) mark before asserting the negative.
ExpectNoMsg(TimeSpan.FromMilliseconds(300));
aggregator.DidNotReceiveWithAnyArgs().MarkHeartbeat(default!, default);
}
/// <summary>Verifies that a real heartbeat still marks the health aggregator.</summary>
[Fact]
public void RealHeartbeat_StillMarksTheHealthAggregator()
{
// The guard must be keyed on the flag alone — an ordinary heartbeat is unaffected,
// including one from a node that predates the field (proto3 defaults it to false).
var (actor, aggregator) = CreateCentralActor();
actor.Tell(new HeartbeatMessage("site-1", "node-a", IsActive: true, Timestamp: T0));
AwaitAssert(() => aggregator.Received(1).MarkHeartbeat("site-1", T0));
}
/// <summary>Verifies that a replicated synthetic heartbeat is also skipped.</summary>
[Fact]
public void SyntheticHeartbeatReplica_IsAlsoSkipped()
{
// Belt-and-braces on the last hop before the aggregator: a peer central node that
// predates the flag could still replicate one.
var (actor, aggregator) = CreateCentralActor();
actor.Tell(new SiteHeartbeatReplica(new HeartbeatMessage(
"site-1", CentralChannelProvider.SyntheticProbeHostname,
IsActive: false, Timestamp: T0, Synthetic: true)));
ExpectNoMsg(TimeSpan.FromMilliseconds(300));
aggregator.DidNotReceiveWithAnyArgs().MarkHeartbeat(default!, default);
}
// ── The wire contract: the flag survives the round trip ─────────────────────────
/// <summary>Verifies that the synthetic flag round-trips through the heartbeat DTO.</summary>
/// <param name="synthetic">The synthetic flag value under test.</param>
[Theory]
[InlineData(true)]
[InlineData(false)]
public void TheSyntheticFlag_RoundTripsThroughTheDto(bool synthetic)
{
var msg = new HeartbeatMessage("site-1", "node-a", IsActive: true, Timestamp: T0,
Synthetic: synthetic);
var back = CentralControlDtoMapper.FromDto(CentralControlDtoMapper.ToDto(msg));
Assert.Equal(synthetic, back.Synthetic);
Assert.Equal(msg with { Synthetic = synthetic }, back);
}
/// <summary>Verifies that a DTO from an older site defaults to not synthetic.</summary>
[Fact]
public void ADtoFromAnOlderSite_DefaultsToNotSynthetic()
{
// proto3 default: a peer that never sets field 5 sends a REAL heartbeat, as before.
var dto = new HeartbeatDto
{
SiteId = "site-1",
NodeHostname = "node-a",
IsActive = true,
Timestamp = Timestamp.FromDateTimeOffset(T0),
};
Assert.False(CentralControlDtoMapper.FromDto(dto).Synthetic);
}
// ── The producer: the failback probe marks itself synthetic ─────────────────────
/// <summary>Verifies that the failback probe marks its heartbeat as synthetic.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task TheFailbackProbe_MarksItsHeartbeatSynthetic()
{
// Two endpoints so a flip is possible; the capture handler answers nothing useful, so
// the probe faults and re-arms — we only care about the request it put on the wire.
var capture = new HeartbeatCapturingHandler();
using var provider = new CentralChannelProvider(
new[] { "http://central-a:8083", "http://central-b:8083" },
new FixedPskProvider("k"),
"site-1",
new CommunicationOptions(),
NullLogger.Instance,
handlerFactory: _ => capture,
probeDeadline: TimeSpan.FromSeconds(2),
backoffBase: TimeSpan.FromMilliseconds(20),
backoffCap: TimeSpan.FromMilliseconds(50));
// Off the preferred endpoint → the background failback probe arms.
provider.ReportUnavailable(0);
var probe = await capture.WaitForHeartbeatAsync(TimeSpan.FromSeconds(10));
Assert.True(probe.Synthetic);
Assert.Equal("site-1", probe.SiteId);
// The hostname is a human-readable label that rides ALONGSIDE the flag; central keys
// its skip on the flag, never on this string.
Assert.Equal(CentralChannelProvider.SyntheticProbeHostname, probe.NodeHostname);
Assert.False(probe.IsActive);
}
private (IActorRef Actor, ICentralHealthAggregator Aggregator) CreateCentralActor()
{
var siteRepo = Substitute.For<ISiteRepository>();
siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>()).Returns(new List<Site>());
var aggregator = Substitute.For<ICentralHealthAggregator>();
var services = new ServiceCollection();
services.AddScoped(_ => siteRepo);
services.AddSingleton(aggregator);
var sp = services.BuildServiceProvider();
var actor = Sys.ActorOf(Props.Create(() =>
new CentralCommunicationActor(sp, Substitute.For<ISiteCommandTransport>())));
return (actor, aggregator);
}
private sealed class FixedPskProvider(string key) : ISitePskProvider
{
/// <inheritdoc />
public ValueTask<string> GetAsync(string siteId, CancellationToken ct) => new(key);
/// <inheritdoc />
public void Invalidate(string siteId) { }
}
/// <summary>
/// Captures the first <c>Heartbeat</c> request body and decodes the length-prefixed gRPC
/// frame back into a <see cref="HeartbeatDto"/>. The response is deliberately a bare 500 so
/// the probe treats the endpoint as still down; the provider swallows that and re-arms.
/// </summary>
private sealed class HeartbeatCapturingHandler : HttpMessageHandler
{
private readonly TaskCompletionSource<HeartbeatDto> _captured =
new(TaskCreationOptions.RunContinuationsAsynchronously);
/// <summary>Waits for the captured heartbeat request.</summary>
/// <param name="timeout">The maximum time to wait.</param>
/// <returns>The decoded heartbeat DTO.</returns>
public async Task<HeartbeatDto> WaitForHeartbeatAsync(TimeSpan timeout)
{
var completed = await Task.WhenAny(_captured.Task, Task.Delay(timeout));
Assert.Same(_captured.Task, completed);
return await _captured.Task;
}
/// <inheritdoc />
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
if (request.Content is not null &&
request.RequestUri?.AbsolutePath.EndsWith("/Heartbeat", StringComparison.Ordinal) == true)
{
var body = await request.Content.ReadAsByteArrayAsync(cancellationToken);
// gRPC frame: 1 compression byte + 4-byte big-endian length + payload.
if (body.Length >= 5)
{
var length = BinaryPrimitives.ReadInt32BigEndian(body.AsSpan(1, 4));
_captured.TrySetResult(
HeartbeatDto.Parser.ParseFrom(body.AsSpan(5, length).ToArray()));
}
}
return new HttpResponseMessage(System.Net.HttpStatusCode.InternalServerError)
{
Version = request.Version,
};
}
}
}