Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/GrpcPullSiteCallsClientTests.cs
T
Joseph Doherty 2ee84af1c0 feat(grpc): PSK-authenticate the site gRPC control plane; drop the vestigial management receptionist registration
Phase 0 of the ClusterClient→gRPC migration
(docs/plans/2026-07-22-clusterclient-to-grpc-plan.md). Standalone hardening: it
closes a gap that exists today and is a precondition for moving command/control
onto gRPC in later phases.

T0.1 — delete the ManagementActor ClusterClientReceptionist registration.
It was built for an out-of-cluster CLI that was never written: the shipped CLI
speaks HTTP Basic to /management, which asks the actor in-process through
ManagementActorHolder. Nothing in the repo ever sent to /user/management. The
actor still runs there; only the cross-boundary advertisement is gone. Six
documents claimed the CLI used ClusterClient — including the CLI's own README
"Architecture Notes" — and are corrected here rather than left to rot.

T0.2 — record, do not port, the dead integration-routing path.
IntegrationCallRequest is unwired at BOTH ends: RouteIntegrationCallAsync has
zero callers anywhere, and RegisterLocalHandler(Integration, …) appears only in
a test, so production always answers "Integration handler not available". It is
excluded from the gRPC contract (28 of 29 commands migrate) rather than
enshrined on an additive-only wire format, and deleting it during a
transport migration would mix a behavioural change into a change whose whole
value is that behaviour is identical. See
docs/known-issues/2026-07-22-integration-call-routing-is-dead-code.md.

T0.3 — preshared-key authentication on SiteStreamService.
The service shipped with no auth at all: plaintext h2c, no interceptor, so
anything that could reach a site node's :8083 could open a live data stream or
read audit rows back via PullAuditEvents/PullSiteCalls. ControlPlaneAuthInterceptor
now gates /sitestream.SiteStreamService/ — modeled on LocalDbSyncAuthInterceptor
(constant-time compare, fail-closed, PermissionDenied) but gating a SET of
service prefixes so phases 1A/1B add services rather than interceptors. LocalDb
sync keeps its own separate key: it authenticates the pair partner, not central,
and collapsing the two would make a site's central-facing key also admit writes
into its database.

Keys are per site (SB-GRPC-PSK-<siteId>), never fleet-wide, so a compromised
site yields only its own. Central attaches them through ControlPlaneCredentials,
which binds CallCredentials to the channel — covering unary and streaming
uniformly, and letting the key resolve asynchronously, which a client
interceptor could not do without blocking. All three central→site channel
creation sites go through it (SiteStreamGrpcClient and both audit pull invokers);
the pull invokers' channel caches are re-keyed by (site, endpoint) because
credentials are per-site and bound to the channel.

Two decisions beyond the plan:

  * StartupValidator now requires GrpcPsk on Site nodes. The plan specified only
    the runtime gate, but fail-closed with no boot check produces a node that
    joins, answers heartbeats and reports healthy while refusing every stream,
    audit pull and telemetry ingest — silent and total. Same reasoning as the
    existing inbound API-key pepper rule.

  * Added Communication:SitePsks as a central-side key map. The plan assumed
    central would read the store, seeded via a dev KEK; the docker rig
    deliberately boots with no master key, so store-only resolution would leave
    it unable to dial its own sites. The store stays primary — it is the only
    source that can serve a site added at runtime — with the map covering
    key-less hosts and one-off pins. Neither source falling back to
    "unauthenticated" is the invariant.

T0.4 — dev keys on both rigs and tests.
34 tests. The seven that matter most exercise a real in-process gRPC stack over
TestServer: the unit tests on either side of the wire would both stay green if
the halves disagreed, and gRPC refuses call credentials on a plaintext channel
by default — the UnsafeUseInsecureChannelCallCredentials opt-in is only provable
by making a real call. They confirm correct key passes on unary AND streaming,
wrong key and no-credentials both get PermissionDenied, and an unresolvable key
fails the call with nothing reaching the service.

OPERATIONAL: a site node upgraded to this build without a key will not boot.
That includes the gitignored deploy/wonder-app-vd03/ overlay.
2026-07-22 17:51:09 -04:00

322 lines
14 KiB
C#

using Google.Protobuf.WellKnownTypes;
using Grpc.Core;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
using ProtoPullRequest = ZB.MOM.WW.ScadaBridge.Communication.Grpc.PullSiteCallsRequest;
using ProtoPullResponse = ZB.MOM.WW.ScadaBridge.Communication.Grpc.PullSiteCallsResponse;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;
/// <summary>
/// Tests for <see cref="GrpcPullSiteCallsClient"/> — the production
/// <see cref="IPullSiteCallsClient"/> that dials a site over gRPC and issues the
/// <c>PullSiteCalls</c> unary RPC for the Site Call Audit (#22) reconciliation
/// loop. The real <c>GrpcChannel</c> is replaced by an injected
/// <see cref="GrpcPullSiteCallsClient.IPullSiteCallsInvoker"/> seam so the
/// client's mapping / ordering / SourceSite-restamp / fault-swallowing behaviour
/// can be asserted without standing up a Kestrel HTTP/2 endpoint. Mirrors
/// <see cref="GrpcPullAuditEventsClientTests"/>.
/// </summary>
public class GrpcPullSiteCallsClientTests
{
private static readonly DateTime BaseTime =
new(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc);
private sealed class StaticEnumerator : ISiteEnumerator
{
private readonly IReadOnlyList<SiteEntry> _sites;
public StaticEnumerator(params SiteEntry[] sites) => _sites = sites;
public Task<IReadOnlyList<SiteEntry>> EnumerateAsync(CancellationToken ct = default) =>
Task.FromResult(_sites);
}
private sealed class FakeInvoker : GrpcPullSiteCallsClient.IPullSiteCallsInvoker
{
public string? Endpoint { get; private set; }
public ProtoPullRequest? Request { get; private set; }
public int CallCount { get; private set; }
private readonly ProtoPullResponse? _response;
private readonly Exception? _throw;
private FakeInvoker(ProtoPullResponse? response, Exception? toThrow)
{
_response = response;
_throw = toThrow;
}
public static FakeInvoker Returning(ProtoPullResponse response) => new(response, null);
public static FakeInvoker Throwing(Exception ex) => new(null, ex);
public Task<ProtoPullResponse> InvokeAsync(
string siteId, string endpoint, ProtoPullRequest request, CancellationToken ct)
{
CallCount++;
Endpoint = endpoint;
Request = request;
if (_throw is not null)
{
throw _throw;
}
return Task.FromResult(_response!);
}
}
/// <summary>
/// Per-endpoint fake invoker (Task 18): dials are routed by endpoint string so
/// a NodeA-down / NodeB-up failover can be scripted. Records every dialed
/// endpoint in order.
/// </summary>
private sealed class PerEndpointInvoker : GrpcPullSiteCallsClient.IPullSiteCallsInvoker
{
private readonly Dictionary<string, Func<ProtoPullResponse>> _byEndpoint;
public List<string> Dialed { get; } = new();
public PerEndpointInvoker(Dictionary<string, Func<ProtoPullResponse>> byEndpoint) =>
_byEndpoint = byEndpoint;
public Task<ProtoPullResponse> InvokeAsync(
string siteId, string endpoint, ProtoPullRequest request, CancellationToken ct)
{
Dialed.Add(endpoint);
// The Func may throw (transport fault) — the client's try/catch handles it.
return Task.FromResult(_byEndpoint[endpoint]());
}
}
// The site leaves SourceSite empty (it is not a tracking-store column); the
// client re-stamps it from the dialed siteId. Mint DTOs with empty SourceSite
// to prove that re-stamp.
private static SiteCallOperationalDto Dto(Guid id, DateTime updatedAtUtc) =>
new()
{
TrackedOperationId = id.ToString(),
Channel = "ApiOutbound",
Target = "ERP.GetOrder",
SourceSite = string.Empty,
SourceNode = "node-a",
Status = "Attempted",
RetryCount = 1,
LastError = string.Empty,
CreatedAtUtc = Timestamp.FromDateTime(BaseTime),
UpdatedAtUtc = Timestamp.FromDateTime(updatedAtUtc),
};
[Fact]
public async Task PullAsync_dials_resolved_endpoint_maps_oldest_first_and_restamps_source_site()
{
var older = Guid.NewGuid();
var newer = Guid.NewGuid();
// Wire delivered newest-first on purpose to prove the client sorts.
var proto = new ProtoPullResponse { MoreAvailable = true };
proto.Operationals.Add(Dto(newer, BaseTime.AddMinutes(5)));
proto.Operationals.Add(Dto(older, BaseTime));
var invoker = FakeInvoker.Returning(proto);
var sut = new GrpcPullSiteCallsClient(
new StaticEnumerator(new SiteEntry("site-a", "http://site-a:8083")),
invoker,
NullLogger<GrpcPullSiteCallsClient>.Instance);
var result = await sut.PullAsync("site-a", BaseTime, afterId: null, batchSize: 256, CancellationToken.None);
// Endpoint resolution + request shaping.
Assert.Equal("http://site-a:8083", invoker.Endpoint);
Assert.NotNull(invoker.Request);
Assert.Equal(256, invoker.Request!.BatchSize);
Assert.Equal(BaseTime, invoker.Request.SinceUtc.ToDateTime());
// Mapping + ordering + MoreAvailable surface.
Assert.True(result.MoreAvailable);
Assert.Equal(2, result.SiteCalls.Count);
Assert.Equal(older, result.SiteCalls[0].TrackedOperationId.Value);
Assert.Equal(newer, result.SiteCalls[1].TrackedOperationId.Value);
// SourceSite re-stamped from the dialed siteId (DTO carried empty).
Assert.Equal("site-a", result.SiteCalls[0].SourceSite);
Assert.Equal("site-a", result.SiteCalls[1].SourceSite);
// Round-tripped fields survive FromDto.
Assert.Equal("ApiOutbound", result.SiteCalls[0].Channel);
Assert.Equal("node-a", result.SiteCalls[0].SourceNode);
Assert.Equal(1, result.SiteCalls[0].RetryCount);
}
[Fact]
public async Task PullAsync_returns_empty_when_site_endpoint_is_unknown()
{
var invoker = FakeInvoker.Returning(new ProtoPullResponse());
var sut = new GrpcPullSiteCallsClient(
new StaticEnumerator(), // no sites registered
invoker,
NullLogger<GrpcPullSiteCallsClient>.Instance);
var result = await sut.PullAsync("site-a", BaseTime, afterId: null, batchSize: 256, CancellationToken.None);
Assert.Empty(result.SiteCalls);
Assert.False(result.MoreAvailable);
Assert.Equal(0, invoker.CallCount); // never dialled — nothing to dial
}
[Theory]
[InlineData(StatusCode.Unavailable)]
[InlineData(StatusCode.DeadlineExceeded)]
[InlineData(StatusCode.Cancelled)]
public async Task PullAsync_swallows_tolerable_transport_faults_to_empty_response(StatusCode code)
{
var invoker = FakeInvoker.Throwing(new RpcException(new Status(code, "transport fault")));
var sut = new GrpcPullSiteCallsClient(
new StaticEnumerator(new SiteEntry("site-a", "http://site-a:8083")),
invoker,
NullLogger<GrpcPullSiteCallsClient>.Instance);
var result = await sut.PullAsync("site-a", BaseTime, afterId: null, batchSize: 256, CancellationToken.None);
Assert.Empty(result.SiteCalls);
Assert.False(result.MoreAvailable);
}
[Fact]
public async Task PullAsync_swallows_connection_layer_faults_to_empty_response()
{
var invoker = FakeInvoker.Throwing(new HttpRequestException("connection refused"));
var sut = new GrpcPullSiteCallsClient(
new StaticEnumerator(new SiteEntry("site-a", "http://site-a:8083")),
invoker,
NullLogger<GrpcPullSiteCallsClient>.Instance);
var result = await sut.PullAsync("site-a", BaseTime, afterId: null, batchSize: 256, CancellationToken.None);
Assert.Empty(result.SiteCalls);
Assert.False(result.MoreAvailable);
}
[Fact]
public async Task PullAsync_swallows_unexpected_faults_to_empty_response()
{
var invoker = FakeInvoker.Throwing(new InvalidOperationException("boom"));
var sut = new GrpcPullSiteCallsClient(
new StaticEnumerator(new SiteEntry("site-a", "http://site-a:8083")),
invoker,
NullLogger<GrpcPullSiteCallsClient>.Instance);
var result = await sut.PullAsync("site-a", BaseTime, afterId: null, batchSize: 256, CancellationToken.None);
Assert.Empty(result.SiteCalls);
Assert.False(result.MoreAvailable);
}
[Fact]
public async Task PullAsync_skips_poison_row_and_returns_the_good_rows()
{
// Poison-row resilience: one malformed operational (an unparseable
// TrackedOperationId fails SiteCallDtoMapper.FromDto → Guid.Parse) must be
// skipped+logged PER ROW rather than sinking the whole batch through the
// outer catch-all. The two good rows survive, re-stamped + oldest-first.
var older = Guid.NewGuid();
var newer = Guid.NewGuid();
var proto = new ProtoPullResponse { MoreAvailable = false };
proto.Operationals.Add(Dto(newer, BaseTime.AddMinutes(5)));
// Malformed row in the middle of the batch.
var bad = Dto(Guid.NewGuid(), BaseTime.AddMinutes(2));
bad.TrackedOperationId = "not-a-guid";
proto.Operationals.Add(bad);
proto.Operationals.Add(Dto(older, BaseTime));
var invoker = FakeInvoker.Returning(proto);
var sut = new GrpcPullSiteCallsClient(
new StaticEnumerator(new SiteEntry("site-a", "http://site-a:8083")),
invoker,
NullLogger<GrpcPullSiteCallsClient>.Instance);
// Must NOT throw — the bad row is dropped, the good rows are returned.
var result = await sut.PullAsync("site-a", BaseTime, afterId: null, batchSize: 256, CancellationToken.None);
Assert.Equal(2, result.SiteCalls.Count);
// Survivors are oldest-first and SourceSite re-stamped from the dialed siteId.
Assert.Equal(older, result.SiteCalls[0].TrackedOperationId.Value);
Assert.Equal(newer, result.SiteCalls[1].TrackedOperationId.Value);
Assert.Equal("site-a", result.SiteCalls[0].SourceSite);
Assert.Equal("site-a", result.SiteCalls[1].SourceSite);
Assert.False(result.MoreAvailable);
}
[Fact]
public async Task PullAsync_with_minvalue_unspecified_cursor_does_not_throw_and_dials()
{
// The reconciliation cursor starts at DateTime.MinValue with
// Kind=Unspecified. EnsureUtc must treat it AS UTC (per the system-wide
// invariant) and NOT call ToUniversalTime() — on a host with a positive
// UTC offset that underflows and Timestamp.FromDateTime throws, crashing
// the FIRST pull for every site.
var minUnspecified = default(DateTime);
Assert.Equal(DateTimeKind.Unspecified, minUnspecified.Kind);
var invoker = FakeInvoker.Returning(new ProtoPullResponse());
var sut = new GrpcPullSiteCallsClient(
new StaticEnumerator(new SiteEntry("site-a", "http://site-a:8083")),
invoker,
NullLogger<GrpcPullSiteCallsClient>.Instance);
var result = await sut.PullAsync("site-a", minUnspecified, afterId: null, batchSize: 256, CancellationToken.None);
Assert.Equal(1, invoker.CallCount);
Assert.Equal("http://site-a:8083", invoker.Endpoint);
Assert.NotNull(invoker.Request);
Assert.Equal(DateTime.MinValue, invoker.Request!.SinceUtc.ToDateTime());
Assert.Empty(result.SiteCalls);
Assert.False(result.MoreAvailable);
}
[Fact]
public async Task PullAsync_fails_over_to_NodeB_when_primary_raises_transport_fault()
{
// Task 18: NodeA (primary) is unreachable, NodeB (fallback) answers — the
// pull must return NodeB's rows rather than collapsing to empty.
var id = Guid.NewGuid();
var fallbackResponse = new ProtoPullResponse();
fallbackResponse.Operationals.Add(Dto(id, BaseTime.AddMinutes(1)));
var invoker = new PerEndpointInvoker(new Dictionary<string, Func<ProtoPullResponse>>
{
["http://node-a:8083"] = () => throw new RpcException(new Status(StatusCode.Unavailable, "node A down")),
["http://node-b:8083"] = () => fallbackResponse,
});
var sut = new GrpcPullSiteCallsClient(
new StaticEnumerator(new SiteEntry("site-a", "http://node-a:8083", "http://node-b:8083")),
invoker,
NullLogger<GrpcPullSiteCallsClient>.Instance);
var result = await sut.PullAsync("site-a", BaseTime, afterId: null, batchSize: 256, CancellationToken.None);
// Primary dialed first, then the NodeB fallback once.
Assert.Equal(new[] { "http://node-a:8083", "http://node-b:8083" }, invoker.Dialed);
var row = Assert.Single(result.SiteCalls);
Assert.Equal(id, row.TrackedOperationId.Value);
Assert.Equal("site-a", row.SourceSite);
}
[Fact]
public async Task PullAsync_does_not_fail_over_when_no_fallback_configured()
{
// A transport fault with no NodeB fallback collapses to empty and dials
// only the primary once (no phantom second dial).
var invoker = new PerEndpointInvoker(new Dictionary<string, Func<ProtoPullResponse>>
{
["http://node-a:8083"] = () => throw new RpcException(new Status(StatusCode.Unavailable, "node A down")),
});
var sut = new GrpcPullSiteCallsClient(
new StaticEnumerator(new SiteEntry("site-a", "http://node-a:8083")), // no fallback
invoker,
NullLogger<GrpcPullSiteCallsClient>.Instance);
var result = await sut.PullAsync("site-a", BaseTime, afterId: null, batchSize: 256, CancellationToken.None);
Assert.Empty(result.SiteCalls);
Assert.Equal(new[] { "http://node-a:8083" }, invoker.Dialed);
}
}