006202f3c7
Component-AuditLog.md has always required "we over-redact, never under-redact, on configuration faults", but the body / SQL-parameter redactors violated it. AuditRegexCache rejects a pattern that is malformed OR whose compile exceeds a 100 ms budget, caching the rejection for the process lifetime. ScadaBridgeAuditRedactor then simply dropped the rejected pattern from its redactor set and emitted the payload anyway — publishing precisely the values the operator configured it to suppress, onto a row that looks entirely normal downstream. Recovery required a process restart and the only signal was one Warning line. The SQL path was worse: TryGetSqlParamRedactor returned a bare false for both "no redactor configured for this connection" and "the configured one will not compile", and CLAUDE.md records SQL parameter capture as on by default. Two changes: 1. Fail closed. A pattern that is CONFIGURED but unavailable now over-redacts the whole payload and increments AuditRedactionFailure, reusing the existing safety-net path. "Not configured at all" stays permissive — conflating those two states is the actual defect, so both are pinned by tests. 2. Precompile off the hot path. The audit-log roadmap specifies patterns are "precompiled at startup; rejected if compile takes >100ms"; the implementation had drifted to compiling lazily on first event, which put a wall-clock budget on a hot path under production load. RegexOptions.Compiled emits IL during construction, so a busy node could blow the budget on a perfectly valid pattern. Warm-up now runs at construction and on every options reload. The residual window between a reload and its warm-up is safe because that path now fails closed. Warm-up deliberately does not fail the boot — an unusable pattern degrades the node to over-redaction (safe, loud) rather than refusing to start. Reading CurrentValue happens inside the warm-up try so an options provider that throws still surfaces via Apply's over-redact path, not the constructor (OuterCatch_OptionsThrows_NeverLeaks_AllSensitiveFieldsOverRedacted). Also de-flakes GrpcCentralTransportTests.DeadlineExceeded_IsNotRetriedOnThePeer, which is how this was found. It black-holed node A behind a 300 ms deadline, but on a saturated machine the call could fail to even START — a genuinely-unsent failure that IsConnectFailure correctly fails over on, so node B's ack arrived instead of the expected Status.Failure. The test read as a flake while actually reporting that its own premise had not held. Split in two: the hard rule now injects an explicit DeadlineExceeded via a trailers-only response (deterministic, load-independent), and a new BlackHoledNode_DoesNotHang covers the deadline-is-actually-applied half with both nodes black-holed so no ack can arrive down any path. Verified: both fixes were confirmed to fail before they pass — reverting the fail-closed guard fails exactly the 5 fail-closed tests while the 4 controls still pass, and adding DeadlineExceeded to IsConnectFailure fails the rewritten transport test. AuditLog 367/367, Host.Tests GrpcCentralTransport 8/8, solution build clean. The previously-intermittent Filter_PicksUp_NewBodyRedactor_OnConfigReload is green in a full sweep for the first time. Not addressed here, and noted on #35: the 100 ms wall-clock budget remains a weak proxy for catastrophic backtracking (RegexOptions.Compiled defers JIT to first match, so construction time measures the wrong thing), and a rejection is still cached permanently. Both are now safe rather than dangerous, so they are hardening rather than a leak.
457 lines
18 KiB
C#
457 lines
18 KiB
C#
using System.Collections.Concurrent;
|
|
using Akka.Actor;
|
|
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.Notification;
|
|
using ZB.MOM.WW.ScadaBridge.Communication;
|
|
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
|
|
|
|
/// <summary>
|
|
/// T1A.3: <see cref="GrpcCentralTransport"/> + <see cref="CentralChannelProvider"/> over a real
|
|
/// gRPC stack (two in-process <see cref="TestServer"/> central nodes, the real
|
|
/// <see cref="CentralControlGrpcService"/> and <see cref="CentralControlAuthInterceptor"/>). Proves
|
|
/// the sticky failover/failback policy, the PSK + site-header attachment, the per-call deadline,
|
|
/// and — the hard rule — no cross-node retry on <c>DeadlineExceeded</c>.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// A "down" node is modelled by a <see cref="ToggleHandler"/> that throws before reaching the
|
|
/// TestServer, so BOTH the unary call and the failback <c>Heartbeat</c> probe see it as
|
|
/// <c>Unavailable</c> — the honest shape of a refused connection, and the only class the transport
|
|
/// fails over on. Readiness is always set, so a node that is "up" answers everything.
|
|
/// </remarks>
|
|
public class GrpcCentralTransportTests : IAsyncLifetime
|
|
{
|
|
private const string SiteA = "site-a";
|
|
private const string SiteAKey = "site-a-preshared-key";
|
|
private const string EndpointA = "http://central-a/";
|
|
private const string EndpointB = "http://central-b/";
|
|
|
|
private ActorSystem _system = null!;
|
|
private CentralNode _nodeA = null!;
|
|
private CentralNode _nodeB = null!;
|
|
|
|
/// <inheritdoc />
|
|
public async Task InitializeAsync()
|
|
{
|
|
_system = ActorSystem.Create("grpc-central-transport-test");
|
|
_nodeA = await CentralNode.StartAsync(_system, "A", SiteA, SiteAKey, repliesToSubmit: true);
|
|
_nodeB = await CentralNode.StartAsync(_system, "B", SiteA, SiteAKey, repliesToSubmit: true);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task DisposeAsync()
|
|
{
|
|
await _nodeA.DisposeAsync();
|
|
await _nodeB.DisposeAsync();
|
|
await _system.Terminate();
|
|
}
|
|
|
|
private CentralChannelProvider NewProvider(string? pskKey = SiteAKey) => new(
|
|
new[] { EndpointA, EndpointB },
|
|
new FixedPskProvider(pskKey),
|
|
SiteA,
|
|
new CommunicationOptions(),
|
|
NullLogger<CentralChannelProvider>.Instance,
|
|
handlerFactory: HandlerFor,
|
|
probeDeadline: TimeSpan.FromSeconds(2),
|
|
backoffBase: TimeSpan.FromMilliseconds(50),
|
|
backoffCap: TimeSpan.FromMilliseconds(200));
|
|
|
|
/// <summary>
|
|
/// When set, node A's handler short-circuits every call with this gRPC status
|
|
/// instead of reaching its TestServer. See <see cref="GrpcStatusHandler"/>.
|
|
/// </summary>
|
|
private Grpc.Core.StatusCode? _nodeAForcedStatus;
|
|
|
|
private HttpMessageHandler HandlerFor(string endpoint) => endpoint == EndpointA
|
|
? new GrpcStatusHandler(
|
|
new ToggleHandler(_nodeA.Server.CreateHandler(), () => _nodeA.IsUp),
|
|
() => _nodeAForcedStatus)
|
|
: new ToggleHandler(_nodeB.Server.CreateHandler(), () => _nodeB.IsUp);
|
|
|
|
private GrpcCentralTransport NewTransport(CentralChannelProvider provider, CommunicationOptions? options = null)
|
|
=> new(provider, options ?? new CommunicationOptions(), NullLogger<GrpcCentralTransport>.Instance);
|
|
|
|
[Fact]
|
|
public async Task HappyPath_ReachesThePreferredNode_AndRoutesTheAckBack()
|
|
{
|
|
using var provider = NewProvider();
|
|
var transport = NewTransport(provider);
|
|
var inbox = new Capture(_system);
|
|
|
|
transport.SubmitNotification(NewSubmit("n1"), inbox.Ref);
|
|
|
|
var ack = Assert.IsType<NotificationSubmitAck>(inbox.Receive(TimeSpan.FromSeconds(5)));
|
|
Assert.True(ack.Accepted);
|
|
Assert.Equal("n1", ack.NotificationId);
|
|
Assert.Equal(0, provider.CurrentIndex); // stayed on preferred
|
|
Assert.Equal(1, _nodeA.SubmitCount);
|
|
Assert.Equal(0, _nodeB.SubmitCount);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Sticky_StaysOnThePreferredNode_WhileHealthy()
|
|
{
|
|
using var provider = NewProvider();
|
|
var transport = NewTransport(provider);
|
|
|
|
for (var i = 0; i < 4; i++)
|
|
{
|
|
var inbox = new Capture(_system);
|
|
transport.SubmitNotification(NewSubmit($"n{i}"), inbox.Ref);
|
|
Assert.IsType<NotificationSubmitAck>(inbox.Receive(TimeSpan.FromSeconds(5)));
|
|
}
|
|
|
|
Assert.Equal(0, provider.CurrentIndex);
|
|
Assert.Equal(4, _nodeA.SubmitCount);
|
|
Assert.Equal(0, _nodeB.SubmitCount);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Failover_FlipsToThePeer_WhenThePreferredIsUnavailable()
|
|
{
|
|
using var provider = NewProvider();
|
|
var transport = NewTransport(provider);
|
|
_nodeA.IsUp = false; // preferred refuses connections
|
|
|
|
var inbox = new Capture(_system);
|
|
transport.SubmitNotification(NewSubmit("n1"), inbox.Ref);
|
|
|
|
var ack = Assert.IsType<NotificationSubmitAck>(inbox.Receive(TimeSpan.FromSeconds(5)));
|
|
Assert.True(ack.Accepted);
|
|
Assert.Equal(1, provider.CurrentIndex); // flipped to the peer
|
|
Assert.Equal(0, _nodeA.SubmitCount);
|
|
Assert.Equal(1, _nodeB.SubmitCount);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Failback_ReturnsToThePreferred_OnceItIsReachableAgain()
|
|
{
|
|
using var provider = NewProvider();
|
|
var transport = NewTransport(provider);
|
|
|
|
// Take the preferred down and drive one call so we flip to the peer + arm the failback probe.
|
|
_nodeA.IsUp = false;
|
|
var inbox = new Capture(_system);
|
|
transport.SubmitNotification(NewSubmit("n1"), inbox.Ref);
|
|
Assert.IsType<NotificationSubmitAck>(inbox.Receive(TimeSpan.FromSeconds(5)));
|
|
Assert.Equal(1, provider.CurrentIndex);
|
|
|
|
// Bring the preferred back; the background probe should fail us back within a few backoffs.
|
|
_nodeA.IsUp = true;
|
|
await WaitUntil(() => provider.CurrentIndex == 0, TimeSpan.FromSeconds(5));
|
|
Assert.Equal(0, provider.CurrentIndex);
|
|
|
|
// New calls resume on the preferred node.
|
|
var inbox2 = new Capture(_system);
|
|
transport.SubmitNotification(NewSubmit("n2"), inbox2.Ref);
|
|
Assert.IsType<NotificationSubmitAck>(inbox2.Receive(TimeSpan.FromSeconds(5)));
|
|
Assert.True(_nodeA.SubmitCount >= 1);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PskAndSiteHeader_AreAttached_SoTheGatedCallReachesTheService()
|
|
{
|
|
// The service is gated by CentralControlAuthInterceptor; a call that reaches it (and gets
|
|
// Accepted) proves both the bearer PSK and the x-scadabridge-site header were attached.
|
|
using var provider = NewProvider(pskKey: SiteAKey);
|
|
var transport = NewTransport(provider);
|
|
var inbox = new Capture(_system);
|
|
|
|
transport.SubmitNotification(NewSubmit("n1"), inbox.Ref);
|
|
|
|
var ack = Assert.IsType<NotificationSubmitAck>(inbox.Receive(TimeSpan.FromSeconds(5)));
|
|
Assert.True(ack.Accepted);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task WrongPsk_IsRejected_AndNotRetriedOnThePeer()
|
|
{
|
|
// PermissionDenied is not a connect failure — the transport surfaces it as a transient
|
|
// Status.Failure without flipping to the peer.
|
|
using var provider = NewProvider(pskKey: "the-wrong-key");
|
|
var transport = NewTransport(provider);
|
|
var inbox = new Capture(_system);
|
|
|
|
transport.SubmitNotification(NewSubmit("n1"), inbox.Ref);
|
|
|
|
Assert.IsType<Status.Failure>(inbox.Receive(TimeSpan.FromSeconds(5)));
|
|
Assert.Equal(0, provider.CurrentIndex); // no flip
|
|
Assert.Equal(0, _nodeB.SubmitCount); // peer never tried
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DeadlineExceeded_IsNotRetriedOnThePeer()
|
|
{
|
|
// THE hard rule: on DeadlineExceeded the call may ALREADY have executed, so the transport
|
|
// must surface Status.Failure and must NOT try node B.
|
|
//
|
|
// The status is injected rather than produced by black-holing node A behind a short
|
|
// deadline. That older setup was load-dependent and failed intermittently in full-solution
|
|
// sweeps: on a saturated machine the call could fail to even START, which IsConnectFailure
|
|
// correctly classifies as provably-unsent, so the transport failed over and node B's ack
|
|
// arrived instead of a Status.Failure. The test then read as a flake while actually
|
|
// reporting that its own premise had not held. Injecting the status makes the failure mode
|
|
// the test's subject rather than a race — see BlackHoledNode_DoesNotHang for the
|
|
// deadline-is-actually-applied half.
|
|
_nodeAForcedStatus = Grpc.Core.StatusCode.DeadlineExceeded;
|
|
|
|
using var provider = NewProvider();
|
|
var transport = NewTransport(provider);
|
|
var inbox = new Capture(_system);
|
|
|
|
transport.SubmitNotification(NewSubmit("n1"), inbox.Ref);
|
|
|
|
Assert.IsType<Status.Failure>(inbox.Receive(TimeSpan.FromSeconds(5)));
|
|
Assert.Equal(0, provider.CurrentIndex); // no failover on a deadline
|
|
Assert.Equal(0, _nodeB.SubmitCount); // peer never tried
|
|
}
|
|
|
|
[Fact]
|
|
public async Task BlackHoledNode_DoesNotHang_APerCallDeadlineIsApplied()
|
|
{
|
|
// The other half of the split: a node that accepts the call and never replies must not
|
|
// hang the caller forever — a per-call deadline bounds it. Both nodes black-hole, so this
|
|
// holds whichever node the transport ends up on and the assertion cannot be perturbed by
|
|
// whether the machine was loaded enough to turn the stall into a connect failure.
|
|
_nodeA.SetBlackHole();
|
|
_nodeB.SetBlackHole();
|
|
var shortDeadline = new CommunicationOptions
|
|
{
|
|
NotificationForwardTimeout = TimeSpan.FromMilliseconds(300),
|
|
};
|
|
|
|
using var provider = NewProvider();
|
|
var transport = NewTransport(provider, shortDeadline);
|
|
var inbox = new Capture(_system);
|
|
|
|
transport.SubmitNotification(NewSubmit("n1"), inbox.Ref);
|
|
|
|
// Returns rather than hanging: the 5 s inbox wait is far longer than the 300 ms deadline,
|
|
// so a missing deadline shows up as a TimeoutException from Receive.
|
|
Assert.IsType<Status.Failure>(inbox.Receive(TimeSpan.FromSeconds(5)));
|
|
}
|
|
|
|
private static NotificationSubmit NewSubmit(string id) => new(
|
|
NotificationId: id,
|
|
ListName: "ops",
|
|
Subject: "s",
|
|
Body: "b",
|
|
SourceSiteId: SiteA,
|
|
SourceInstanceId: null,
|
|
SourceScript: null,
|
|
SiteEnqueuedAt: DateTimeOffset.UtcNow);
|
|
|
|
private static async Task WaitUntil(Func<bool> condition, TimeSpan timeout)
|
|
{
|
|
var deadline = DateTime.UtcNow + timeout;
|
|
while (DateTime.UtcNow < deadline)
|
|
{
|
|
if (condition())
|
|
{
|
|
return;
|
|
}
|
|
|
|
await Task.Delay(25);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// A raw message sink used as the transport's <c>replyTo</c>. Unlike Akka's <c>Inbox</c>, which
|
|
/// rethrows a <see cref="Status.Failure"/>'s cause on receive, this captures every message
|
|
/// verbatim so a test can assert on the <see cref="Status.Failure"/> itself.
|
|
/// </summary>
|
|
private sealed class Capture
|
|
{
|
|
private readonly BlockingCollection<object> _messages = new();
|
|
|
|
public Capture(ActorSystem system)
|
|
{
|
|
Ref = system.ActorOf(Props.Create(() => new CaptureActor(_messages)));
|
|
}
|
|
|
|
public IActorRef Ref { get; }
|
|
|
|
public object Receive(TimeSpan timeout)
|
|
=> _messages.TryTake(out var message, timeout)
|
|
? message
|
|
: throw new TimeoutException("No message captured within the timeout.");
|
|
|
|
private sealed class CaptureActor : ReceiveActor
|
|
{
|
|
public CaptureActor(BlockingCollection<object> messages) => ReceiveAny(messages.Add);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Short-circuits a call with a chosen gRPC status, so a test can pick the exact failure class
|
|
/// the transport must classify instead of trying to provoke it with timing.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Emits a trailers-only response: HTTP 200 with <c>grpc-status</c> in the HEADERS and an empty
|
|
/// body, which is the shape gRPC defines for a call that fails before producing a message and
|
|
/// which <c>Grpc.Net.Client</c> surfaces as an <c>RpcException</c> carrying that status.
|
|
/// </remarks>
|
|
private sealed class GrpcStatusHandler : DelegatingHandler
|
|
{
|
|
private readonly Func<Grpc.Core.StatusCode?> _forced;
|
|
|
|
public GrpcStatusHandler(HttpMessageHandler inner, Func<Grpc.Core.StatusCode?> forced)
|
|
{
|
|
InnerHandler = inner;
|
|
_forced = forced;
|
|
}
|
|
|
|
protected override async Task<HttpResponseMessage> SendAsync(
|
|
HttpRequestMessage request, CancellationToken cancellationToken)
|
|
{
|
|
var forced = _forced();
|
|
if (forced == null)
|
|
{
|
|
return await base.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
var response = new HttpResponseMessage(System.Net.HttpStatusCode.OK)
|
|
{
|
|
Version = new Version(2, 0),
|
|
Content = new ByteArrayContent(Array.Empty<byte>()),
|
|
RequestMessage = request,
|
|
};
|
|
response.Content.Headers.ContentType =
|
|
new System.Net.Http.Headers.MediaTypeHeaderValue("application/grpc");
|
|
response.Headers.TryAddWithoutValidation(
|
|
"grpc-status", ((int)forced.Value).ToString(System.Globalization.CultureInfo.InvariantCulture));
|
|
response.Headers.TryAddWithoutValidation("grpc-message", "injected by GrpcStatusHandler");
|
|
return response;
|
|
}
|
|
}
|
|
|
|
/// <summary>A gRPC channel handler that throws (a refused connection) while its node is "down".</summary>
|
|
private sealed class ToggleHandler : DelegatingHandler
|
|
{
|
|
private readonly Func<bool> _isUp;
|
|
|
|
public ToggleHandler(HttpMessageHandler inner, Func<bool> isUp)
|
|
{
|
|
InnerHandler = inner;
|
|
_isUp = isUp;
|
|
}
|
|
|
|
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
|
{
|
|
if (!_isUp())
|
|
{
|
|
throw new HttpRequestException("simulated central node down");
|
|
}
|
|
|
|
return await base.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
private sealed class FixedPskProvider(string? key) : ISitePskProvider
|
|
{
|
|
public ValueTask<string> GetAsync(string siteId, CancellationToken ct)
|
|
=> key is null ? throw new InvalidOperationException("no key") : new ValueTask<string>(key);
|
|
|
|
public void Invalidate(string siteId) { }
|
|
}
|
|
|
|
/// <summary>One in-process central node: TestServer + real service/interceptor + a stub actor.</summary>
|
|
private sealed class CentralNode : IAsyncDisposable
|
|
{
|
|
private IHost _host = null!;
|
|
private IActorRef _stub = null!;
|
|
private readonly StubCounters _counters = new();
|
|
|
|
public TestServer Server { get; private set; } = null!;
|
|
public volatile bool IsUp = true;
|
|
public int SubmitCount => _counters.Submits;
|
|
|
|
public static async Task<CentralNode> StartAsync(
|
|
ActorSystem system, string label, string site, string key, bool repliesToSubmit)
|
|
{
|
|
var node = new CentralNode();
|
|
node._stub = system.ActorOf(
|
|
Props.Create(() => new StubCentralActor(node._counters, repliesToSubmit)), $"stub-{label}");
|
|
|
|
var service = new CentralControlGrpcService(
|
|
NullLogger<CentralControlGrpcService>.Instance,
|
|
Options.Create(new CommunicationOptions()));
|
|
service.SetReady(node._stub);
|
|
|
|
var psk = new MapPskProvider(new Dictionary<string, string> { [site] = key });
|
|
|
|
node._host = await new HostBuilder()
|
|
.ConfigureWebHost(web => web
|
|
.UseTestServer()
|
|
.ConfigureServices(services =>
|
|
{
|
|
services.AddGrpc(o => o.Interceptors.Add<CentralControlAuthInterceptor>());
|
|
services.AddSingleton<ISitePskProvider>(psk);
|
|
services.AddSingleton(service);
|
|
})
|
|
.Configure(app =>
|
|
{
|
|
app.UseRouting();
|
|
app.UseEndpoints(e => e.MapGrpcService<CentralControlGrpcService>());
|
|
}))
|
|
.StartAsync();
|
|
|
|
node.Server = node._host.GetTestServer();
|
|
return node;
|
|
}
|
|
|
|
/// <summary>Switches the node's actor to a black hole that counts but never replies.</summary>
|
|
public void SetBlackHole() => _counters.BlackHole = true;
|
|
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
await _host.StopAsync();
|
|
_host.Dispose();
|
|
}
|
|
|
|
private sealed class StubCounters
|
|
{
|
|
private int _submits;
|
|
public int Submits => Volatile.Read(ref _submits);
|
|
public void IncrementSubmits() => Interlocked.Increment(ref _submits);
|
|
public volatile bool BlackHole;
|
|
}
|
|
|
|
private sealed class StubCentralActor : ReceiveActor
|
|
{
|
|
public StubCentralActor(StubCounters counters, bool repliesToSubmit)
|
|
{
|
|
Receive<NotificationSubmit>(msg =>
|
|
{
|
|
counters.IncrementSubmits();
|
|
if (repliesToSubmit && !counters.BlackHole)
|
|
{
|
|
Sender.Tell(new NotificationSubmitAck(msg.NotificationId, Accepted: true, Error: null));
|
|
}
|
|
});
|
|
|
|
// Heartbeat lands here as a Tell (the failback probe); ignore it, no reply expected.
|
|
ReceiveAny(_ => { });
|
|
}
|
|
}
|
|
|
|
private sealed class MapPskProvider(IReadOnlyDictionary<string, string> keys) : ISitePskProvider
|
|
{
|
|
public ValueTask<string> GetAsync(string siteId, CancellationToken ct)
|
|
=> keys.TryGetValue(siteId, out var key)
|
|
? new ValueTask<string>(key)
|
|
: throw new InvalidOperationException($"no key for '{siteId}'");
|
|
|
|
public void Invalidate(string siteId) { }
|
|
}
|
|
}
|
|
}
|