fix(audit): fail closed when a configured redactor is unavailable (#35)
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.
This commit is contained in:
@@ -406,6 +406,31 @@ operational `SiteCalls` shape for the dispatcher and UI.
|
|||||||
- **Safety net** — if a configured redactor throws, the affected payload becomes
|
- **Safety net** — if a configured redactor throws, the affected payload becomes
|
||||||
`"<redacted: redactor error>"` and `AuditRedactionFailure` increments. We
|
`"<redacted: redactor error>"` and `AuditRedactionFailure` increments. We
|
||||||
over-redact, never under-redact, on configuration faults.
|
over-redact, never under-redact, on configuration faults.
|
||||||
|
- **Unavailable redactors fail closed** — a configured pattern that will not
|
||||||
|
compile (malformed, or over the regex cache's 100 ms compile budget) makes the
|
||||||
|
whole payload over-redact for that row, exactly as a throwing redactor does.
|
||||||
|
It is NOT dropped from the redactor set. Dropping it published precisely the
|
||||||
|
values the operator configured it to suppress, on a row that looked entirely
|
||||||
|
normal downstream — a silent under-redaction, which this section has always
|
||||||
|
forbidden (Gitea #35). The distinction that matters is *configured but
|
||||||
|
unavailable* (suppress) versus *not configured at all* (capture as normal);
|
||||||
|
conflating the two is what caused the defect, so both cases are pinned by
|
||||||
|
`AuditRedactorFailClosedTests`.
|
||||||
|
|
||||||
|
Redactor patterns are precompiled when the options snapshot is bound and again on
|
||||||
|
every reload, so the compile budget is spent off the audit hot path. Compiling
|
||||||
|
lazily on first event instead put a wall-clock budget on a hot path under
|
||||||
|
production load, where `RegexOptions.Compiled` IL emission can exceed it for a
|
||||||
|
perfectly valid pattern — and a rejection is cached for the process lifetime, so
|
||||||
|
one unlucky moment disabled that redactor until restart. A narrow window remains
|
||||||
|
between a reload and its warm-up; an event landing there compiles on the hot path
|
||||||
|
and, if rejected, fails closed, so the worst case is over-redaction.
|
||||||
|
|
||||||
|
An unusable pattern does **not** fail the boot: the node degrades to
|
||||||
|
over-redaction (safe and loud via `AuditRedactionFailure` plus a startup warning
|
||||||
|
naming the count) rather than refusing to start. Operators should treat a
|
||||||
|
non-zero `AuditRedactionFailure` with over-redacted payloads as "fix the
|
||||||
|
pattern", not as a payload-capture problem.
|
||||||
|
|
||||||
Redaction happens at the write site, before the row touches SQLite (or central
|
Redaction happens at the write site, before the row touches SQLite (or central
|
||||||
MS SQL for direct-write events). Unredacted secrets never persist.
|
MS SQL for direct-write events). Unredacted secrets never persist.
|
||||||
|
|||||||
@@ -46,6 +46,12 @@ internal sealed class AuditRegexCache
|
|||||||
/// compile time "invalid"); the failure is logged once and the sentinel
|
/// compile time "invalid"); the failure is logged once and the sentinel
|
||||||
/// cache entry prevents repeat compile attempts.
|
/// cache entry prevents repeat compile attempts.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A <c>false</c> return for a pattern the operator actually configured means
|
||||||
|
/// "this redactor is unavailable", NOT "there is nothing to redact". The
|
||||||
|
/// caller MUST treat the two differently and fail closed — see
|
||||||
|
/// <see cref="ZB.MOM.WW.ScadaBridge.AuditLog.Redaction.ScadaBridgeAuditRedactor"/>.
|
||||||
|
/// </remarks>
|
||||||
/// <param name="pattern">The regex pattern string to look up or compile.</param>
|
/// <param name="pattern">The regex pattern string to look up or compile.</param>
|
||||||
/// <param name="regex">The compiled <see cref="Regex"/>, or <c>null</c> if the pattern is invalid.</param>
|
/// <param name="regex">The compiled <see cref="Regex"/>, or <c>null</c> if the pattern is invalid.</param>
|
||||||
/// <returns><c>true</c> if the pattern compiled successfully; <c>false</c> if it is invalid or too slow to compile.</returns>
|
/// <returns><c>true</c> if the pattern compiled successfully; <c>false</c> if it is invalid or too slow to compile.</returns>
|
||||||
@@ -56,6 +62,52 @@ internal sealed class AuditRegexCache
|
|||||||
return entry.Regex != null;
|
return entry.Regex != null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Compile the supplied patterns up front so the 100 ms budget is measured
|
||||||
|
/// once, off the audit hot path.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The audit-log roadmap specifies these patterns are "precompiled at startup;
|
||||||
|
/// rejected if compile takes >100ms" and that catastrophic-backtracking
|
||||||
|
/// candidates are "rejected at startup". Compiling lazily on first event
|
||||||
|
/// instead measured a wall-clock budget on a hot path under production load,
|
||||||
|
/// where <see cref="RegexOptions.Compiled"/> IL emission can exceed 100 ms for
|
||||||
|
/// a perfectly valid pattern — rejecting it, and (before the caller was made
|
||||||
|
/// to fail closed) silently emitting the payload unredacted.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Warming is idempotent and cheap to repeat: already-cached patterns short
|
||||||
|
/// circuit in <see cref="ConcurrentDictionary{TKey,TValue}.GetOrAdd(TKey, Func{TKey, TValue})"/>.
|
||||||
|
/// Call it once at construction and again whenever the options snapshot
|
||||||
|
/// changes. Warming never throws — a pattern that fails here caches its
|
||||||
|
/// sentinel exactly as it would have on the hot path.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="patterns">Patterns to precompile; <c>null</c> entries are skipped.</param>
|
||||||
|
/// <returns>The number of supplied patterns that are unavailable (invalid or over budget).</returns>
|
||||||
|
public int Warm(IEnumerable<string?>? patterns)
|
||||||
|
{
|
||||||
|
if (patterns == null)
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
var unavailable = 0;
|
||||||
|
foreach (var pattern in patterns)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(pattern))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!TryGet(pattern, out _))
|
||||||
|
{
|
||||||
|
unavailable++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return unavailable;
|
||||||
|
}
|
||||||
|
|
||||||
private CompiledRegex Compile(string pattern)
|
private CompiledRegex Compile(string pattern)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ namespace ZB.MOM.WW.ScadaBridge.AuditLog.Redaction;
|
|||||||
/// <see cref="SafeDefaultAuditRedactor"/>.
|
/// <see cref="SafeDefaultAuditRedactor"/>.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed class ScadaBridgeAuditRedactor : IAuditRedactor
|
public sealed class ScadaBridgeAuditRedactor : IAuditRedactor, IDisposable
|
||||||
{
|
{
|
||||||
private const string OverRedactedMarker = AuditRedactionPrimitives.OverRedactedEventMarker;
|
private const string OverRedactedMarker = AuditRedactionPrimitives.OverRedactedEventMarker;
|
||||||
|
|
||||||
@@ -54,6 +54,7 @@ public sealed class ScadaBridgeAuditRedactor : IAuditRedactor
|
|||||||
private readonly ILogger<ScadaBridgeAuditRedactor> _logger;
|
private readonly ILogger<ScadaBridgeAuditRedactor> _logger;
|
||||||
private readonly IAuditRedactionFailureCounter _failureCounter;
|
private readonly IAuditRedactionFailureCounter _failureCounter;
|
||||||
private readonly AuditRegexCache _regexCache;
|
private readonly AuditRegexCache _regexCache;
|
||||||
|
private readonly IDisposable? _optionsReload;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Primary constructor used by DI — pulls the optional redaction-failure
|
/// Primary constructor used by DI — pulls the optional redaction-failure
|
||||||
@@ -71,8 +72,87 @@ public sealed class ScadaBridgeAuditRedactor : IAuditRedactor
|
|||||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
_failureCounter = failureCounter ?? new NoOpAuditRedactionFailureCounter();
|
_failureCounter = failureCounter ?? new NoOpAuditRedactionFailureCounter();
|
||||||
_regexCache = new AuditRegexCache(_logger);
|
_regexCache = new AuditRegexCache(_logger);
|
||||||
|
|
||||||
|
// Precompile at construction and on every reload, per the audit-log
|
||||||
|
// roadmap ("patterns precompiled at startup; rejected if compile takes
|
||||||
|
// >100ms"). Compiling lazily on first event instead put a wall-clock
|
||||||
|
// budget on the hot path, where RegexOptions.Compiled IL emission can
|
||||||
|
// exceed it under load and reject a valid pattern.
|
||||||
|
WarmCurrentPatterns();
|
||||||
|
_optionsReload = _options.OnChange(WarmRedactorPatterns);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Warm from the current options snapshot. Reading <c>CurrentValue</c> happens
|
||||||
|
/// INSIDE the try: an options provider that throws must not take the
|
||||||
|
/// constructor down — <see cref="Apply"/> owns that failure and answers it by
|
||||||
|
/// over-redacting, which is the behaviour
|
||||||
|
/// <c>OuterCatch_OptionsThrows_NeverLeaks_AllSensitiveFieldsOverRedacted</c>
|
||||||
|
/// pins.
|
||||||
|
/// </summary>
|
||||||
|
private void WarmCurrentPatterns()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
WarmRedactorPatterns(_options.CurrentValue);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
ex, "Could not read audit options to precompile redactor patterns; deferring to first use");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Compile every configured body / SQL-parameter redactor pattern so the
|
||||||
|
/// compile budget is spent once, off the audit hot path.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Warming is best-effort and deliberately does NOT fail the boot: an
|
||||||
|
/// unusable pattern degrades that node to over-redaction (safe, loud) rather
|
||||||
|
/// than taking the node down. There is still a narrow window after a reload
|
||||||
|
/// where an event can beat the warm-up and compile on the hot path — that
|
||||||
|
/// path now fails closed, so the worst case is over-redaction, never a
|
||||||
|
/// silently unredacted payload.
|
||||||
|
/// </remarks>
|
||||||
|
private void WarmRedactorPatterns(AuditLogOptions? opts)
|
||||||
|
{
|
||||||
|
if (opts == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var unavailable = _regexCache.Warm(opts.GlobalBodyRedactors);
|
||||||
|
foreach (var over in opts.PerTargetOverrides.Values)
|
||||||
|
{
|
||||||
|
unavailable += _regexCache.Warm(over.AdditionalBodyRedactors);
|
||||||
|
if (!string.IsNullOrEmpty(over.RedactSqlParamsMatching))
|
||||||
|
{
|
||||||
|
unavailable += _regexCache.Warm(new[] { "(?i)" + over.RedactSqlParamsMatching });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unavailable > 0)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"{Count} configured audit redactor pattern(s) are unavailable; audit payloads "
|
||||||
|
+ "matching them will be over-redacted until the patterns are fixed.",
|
||||||
|
unavailable);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// Never let warm-up break construction or an options reload; the
|
||||||
|
// lazy path remains as the (fail-closed) fallback.
|
||||||
|
_logger.LogWarning(ex, "Audit redactor pattern warm-up failed; patterns will compile on first use");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void Dispose() => _optionsReload?.Dispose();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Applies the full redaction pipeline to <paramref name="rawEvent"/> and returns a
|
/// Applies the full redaction pipeline to <paramref name="rawEvent"/> and returns a
|
||||||
/// filtered copy; returns the same instance unchanged on the fast path. Never throws.
|
/// filtered copy; returns the same instance unchanged on the fast path. Never throws.
|
||||||
@@ -116,7 +196,16 @@ public sealed class ScadaBridgeAuditRedactor : IAuditRedactor
|
|||||||
|
|
||||||
// --- Body-regex stage (also runs BEFORE truncation) -----------
|
// --- Body-regex stage (also runs BEFORE truncation) -----------
|
||||||
// Per-target additions key on the canonical Target.
|
// Per-target additions key on the canonical Target.
|
||||||
var bodyRegexes = ResolveBodyRegexes(opts, rawEvent.Target);
|
var bodyRegexes = ResolveBodyRegexes(opts, rawEvent.Target, out var bodyRedactorUnavailable);
|
||||||
|
|
||||||
|
// FAIL CLOSED. A configured redactor that will not compile must never
|
||||||
|
// degrade to "emit the body raw" — that publishes precisely the values
|
||||||
|
// the operator asked to suppress, on a row that looks entirely normal.
|
||||||
|
if (bodyRedactorUnavailable)
|
||||||
|
{
|
||||||
|
return OverRedactUnavailable(rawEvent, "body");
|
||||||
|
}
|
||||||
|
|
||||||
if (bodyRegexes.Count > 0)
|
if (bodyRegexes.Count > 0)
|
||||||
{
|
{
|
||||||
request = RedactBody(request, bodyRegexes);
|
request = RedactBody(request, bodyRegexes);
|
||||||
@@ -128,10 +217,17 @@ public sealed class ScadaBridgeAuditRedactor : IAuditRedactor
|
|||||||
// --- SQL parameter redaction stage (DbOutbound only) ----------
|
// --- SQL parameter redaction stage (DbOutbound only) ----------
|
||||||
// Channel-guarded on the canonical Category; connection key is the
|
// Channel-guarded on the canonical Category; connection key is the
|
||||||
// Target prefix before the first '.'.
|
// Target prefix before the first '.'.
|
||||||
if (string.Equals(rawEvent.Category, nameof(AuditChannel.DbOutbound), StringComparison.Ordinal)
|
if (string.Equals(rawEvent.Category, nameof(AuditChannel.DbOutbound), StringComparison.Ordinal))
|
||||||
&& TryGetSqlParamRedactor(opts, rawEvent.Target, out var sqlParamRegex))
|
|
||||||
{
|
{
|
||||||
request = RedactSqlParameters(request, sqlParamRegex!);
|
if (TryGetSqlParamRedactor(
|
||||||
|
opts, rawEvent.Target, out var sqlParamRegex, out var sqlRedactorUnavailable))
|
||||||
|
{
|
||||||
|
request = RedactSqlParameters(request, sqlParamRegex!);
|
||||||
|
}
|
||||||
|
else if (sqlRedactorUnavailable)
|
||||||
|
{
|
||||||
|
return OverRedactUnavailable(rawEvent, "SQL-parameter");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Truncation stage -----------------------------------------
|
// --- Truncation stage -----------------------------------------
|
||||||
@@ -219,6 +315,23 @@ public sealed class ScadaBridgeAuditRedactor : IAuditRedactor
|
|||||||
return outcome != AuditOutcome.Success;
|
return outcome != AuditOutcome.Success;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fail-closed exit: a redactor the operator configured is unavailable, so the
|
||||||
|
/// payload is suppressed wholesale rather than emitted unredacted. Counted on
|
||||||
|
/// the same <see cref="IAuditRedactionFailureCounter"/> as other redaction
|
||||||
|
/// failures so a disabled redactor is observable, not just a log line.
|
||||||
|
/// </summary>
|
||||||
|
private AuditEvent OverRedactUnavailable(AuditEvent rawEvent, string stage)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"A configured {Stage} redactor is unavailable (invalid or over the compile budget); "
|
||||||
|
+ "over-redacting DetailsJson rather than emitting it unredacted. "
|
||||||
|
+ "Fix or remove the pattern — audit payloads stay suppressed until then.",
|
||||||
|
stage);
|
||||||
|
IncrementFailureCounter();
|
||||||
|
return OverRedact(rawEvent);
|
||||||
|
}
|
||||||
|
|
||||||
private string? RedactHeaders(string? json, IList<string> redactList)
|
private string? RedactHeaders(string? json, IList<string> redactList)
|
||||||
=> AuditRedactionPrimitives.RedactHeaders(json, redactList, _logger, IncrementFailureCounter);
|
=> AuditRedactionPrimitives.RedactHeaders(json, redactList, _logger, IncrementFailureCounter);
|
||||||
|
|
||||||
@@ -236,11 +349,20 @@ public sealed class ScadaBridgeAuditRedactor : IAuditRedactor
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Combine the global and per-target body-redactor lists, returning the
|
/// Combine the global and per-target body-redactor lists, returning the
|
||||||
/// compiled-regex set to apply. Patterns that failed compilation are
|
/// compiled-regex set to apply.
|
||||||
/// silently skipped.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private IReadOnlyList<Regex> ResolveBodyRegexes(AuditLogOptions opts, string? target)
|
/// <remarks>
|
||||||
|
/// <paramref name="anyUnavailable"/> is set when a pattern the operator
|
||||||
|
/// CONFIGURED could not be compiled (invalid, or over the cache's compile
|
||||||
|
/// budget). The caller must then fail closed — dropping the pattern and
|
||||||
|
/// emitting the body anyway would silently publish exactly the values the
|
||||||
|
/// operator asked to have redacted.
|
||||||
|
/// </remarks>
|
||||||
|
private IReadOnlyList<Regex> ResolveBodyRegexes(
|
||||||
|
AuditLogOptions opts, string? target, out bool anyUnavailable)
|
||||||
{
|
{
|
||||||
|
anyUnavailable = false;
|
||||||
|
|
||||||
var hasGlobal = opts.GlobalBodyRedactors is { Count: > 0 };
|
var hasGlobal = opts.GlobalBodyRedactors is { Count: > 0 };
|
||||||
var perTargetAdditions = (target != null
|
var perTargetAdditions = (target != null
|
||||||
&& opts.PerTargetOverrides.TryGetValue(target, out var over)
|
&& opts.PerTargetOverrides.TryGetValue(target, out var over)
|
||||||
@@ -262,6 +384,10 @@ public sealed class ScadaBridgeAuditRedactor : IAuditRedactor
|
|||||||
{
|
{
|
||||||
result.Add(rx!);
|
result.Add(rx!);
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
anyUnavailable = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (perTargetAdditions != null)
|
if (perTargetAdditions != null)
|
||||||
@@ -272,6 +398,10 @@ public sealed class ScadaBridgeAuditRedactor : IAuditRedactor
|
|||||||
{
|
{
|
||||||
result.Add(rx!);
|
result.Add(rx!);
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
anyUnavailable = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
@@ -282,9 +412,18 @@ public sealed class ScadaBridgeAuditRedactor : IAuditRedactor
|
|||||||
/// target. Connection key = everything before the first <c>.</c> in
|
/// target. Connection key = everything before the first <c>.</c> in
|
||||||
/// <paramref name="target"/>. Patterns are forced case-insensitive.
|
/// <paramref name="target"/>. Patterns are forced case-insensitive.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private bool TryGetSqlParamRedactor(AuditLogOptions opts, string? target, out Regex? regex)
|
/// <remarks>
|
||||||
|
/// <paramref name="unavailable"/> separates "no SQL redactor is configured for
|
||||||
|
/// this connection" (fine — return false, capture parameters as normal) from
|
||||||
|
/// "one IS configured but will not compile" (fail closed). Collapsing the two
|
||||||
|
/// into a bare <c>false</c> is what let a configured redactor silently emit
|
||||||
|
/// parameter values verbatim.
|
||||||
|
/// </remarks>
|
||||||
|
private bool TryGetSqlParamRedactor(
|
||||||
|
AuditLogOptions opts, string? target, out Regex? regex, out bool unavailable)
|
||||||
{
|
{
|
||||||
regex = null;
|
regex = null;
|
||||||
|
unavailable = false;
|
||||||
if (string.IsNullOrEmpty(target))
|
if (string.IsNullOrEmpty(target))
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
@@ -300,7 +439,13 @@ public sealed class ScadaBridgeAuditRedactor : IAuditRedactor
|
|||||||
}
|
}
|
||||||
|
|
||||||
var cacheKey = "(?i)" + over.RedactSqlParamsMatching;
|
var cacheKey = "(?i)" + over.RedactSqlParamsMatching;
|
||||||
return _regexCache.TryGet(cacheKey, out regex);
|
if (_regexCache.TryGet(cacheKey, out regex))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
unavailable = true;
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -0,0 +1,332 @@
|
|||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using ZB.MOM.WW.Audit;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.AuditLog.Redaction;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Redaction;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Guards the fail-closed contract for unavailable redactor patterns
|
||||||
|
/// (Gitea #35).
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// <b>The defect.</b> <see cref="AuditRegexCache"/> rejects a pattern that is
|
||||||
|
/// invalid OR whose compile exceeds a 100 ms budget, and caches that rejection
|
||||||
|
/// for the process lifetime. <see cref="ScadaBridgeAuditRedactor"/> then dropped
|
||||||
|
/// the rejected pattern from its redactor list and emitted the payload anyway —
|
||||||
|
/// publishing exactly the values the operator configured it to suppress, on a
|
||||||
|
/// row that looks completely normal downstream.
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// <b>Why it hid.</b> The budget is wall-clock timed and, before the warm-up
|
||||||
|
/// added alongside these tests, was measured on the audit hot path.
|
||||||
|
/// <c>RegexOptions.Compiled</c> emits IL during construction, so a busy node
|
||||||
|
/// could blow the budget on a perfectly valid pattern. It surfaced only as an
|
||||||
|
/// intermittent failure of
|
||||||
|
/// <c>AuditLogOptionsBindingTests.Filter_PicksUp_NewBodyRedactor_OnConfigReload</c>
|
||||||
|
/// under a loaded full-solution sweep, where it read as a flake.
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// <b>Why these tests use an invalid pattern.</b> An unclosed character class
|
||||||
|
/// fails compilation deterministically and reaches the SAME rejection sentinel
|
||||||
|
/// as a timing rejection. Driving the 100 ms budget directly would require
|
||||||
|
/// making the machine slow on demand — the very nondeterminism that let the
|
||||||
|
/// defect through. The lever differs from production; the code path does not.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public class AuditRedactorFailClosedTests
|
||||||
|
{
|
||||||
|
/// <summary>Unclosed character class — never compiles, on any machine, at any load.</summary>
|
||||||
|
private const string UncompilablePattern = "[unterminated";
|
||||||
|
|
||||||
|
private const string WorkingPattern = "\"password\":\\s*\"[^\"]*\"";
|
||||||
|
|
||||||
|
private const string Secret = "hunter2";
|
||||||
|
|
||||||
|
private static AuditEvent NewEvent(
|
||||||
|
string? target = null,
|
||||||
|
AuditChannel channel = AuditChannel.ApiOutbound)
|
||||||
|
{
|
||||||
|
var details = new AuditDetails
|
||||||
|
{
|
||||||
|
RequestSummary = $"{{\"user\":\"alice\",\"password\":\"{Secret}\"}}",
|
||||||
|
Status = nameof(AuditStatus.Delivered),
|
||||||
|
};
|
||||||
|
return new AuditEvent
|
||||||
|
{
|
||||||
|
EventId = Guid.NewGuid(),
|
||||||
|
OccurredAtUtc = DateTimeOffset.UtcNow,
|
||||||
|
Actor = "tester",
|
||||||
|
Action = AuditFieldBuilders.BuildAction(channel, AuditKind.ApiCall),
|
||||||
|
Category = AuditFieldBuilders.BuildCategory(channel),
|
||||||
|
Outcome = AuditOutcome.Success,
|
||||||
|
Target = target,
|
||||||
|
DetailsJson = AuditDetailsCodec.Serialize(details),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (ScadaBridgeAuditRedactor Redactor, CountingCounter Counter) Build(AuditLogOptions opts)
|
||||||
|
{
|
||||||
|
var counter = new CountingCounter();
|
||||||
|
var redactor = new ScadaBridgeAuditRedactor(
|
||||||
|
new StaticMonitor(opts), NullLogger<ScadaBridgeAuditRedactor>.Instance, counter);
|
||||||
|
return (redactor, counter);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? RequestOf(AuditEvent e) =>
|
||||||
|
AuditDetailsCodec.Deserialize(e.DetailsJson).RequestSummary;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Positive control. Without it the "secret is absent" assertions below would
|
||||||
|
/// also pass if the redactor simply dropped every payload for any reason, and
|
||||||
|
/// would prove nothing about the fail-closed path specifically.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Control_WorkingPattern_RedactsTheSecretAndKeepsTheRestOfTheBody()
|
||||||
|
{
|
||||||
|
var (redactor, counter) = Build(new AuditLogOptions
|
||||||
|
{
|
||||||
|
GlobalBodyRedactors = new List<string> { WorkingPattern },
|
||||||
|
});
|
||||||
|
|
||||||
|
var result = RequestOf(redactor.Apply(NewEvent()));
|
||||||
|
|
||||||
|
Assert.DoesNotContain(Secret, result);
|
||||||
|
Assert.Contains("alice", result); // a working redactor is surgical, not wholesale
|
||||||
|
Assert.Equal(0, counter.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Second control: with NO redactors configured the body is emitted verbatim.
|
||||||
|
/// This pins the distinction the fix turns on — "nothing to redact" must stay
|
||||||
|
/// permissive, and only "configured but unavailable" may suppress.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void Control_NoRedactorsConfigured_EmitsBodyUnchanged()
|
||||||
|
{
|
||||||
|
var (redactor, counter) = Build(new AuditLogOptions());
|
||||||
|
|
||||||
|
var result = RequestOf(redactor.Apply(NewEvent()));
|
||||||
|
|
||||||
|
Assert.Contains(Secret, result);
|
||||||
|
Assert.Equal(0, counter.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UnavailableGlobalBodyRedactor_SuppressesPayload_RatherThanEmittingItRaw()
|
||||||
|
{
|
||||||
|
var (redactor, counter) = Build(new AuditLogOptions
|
||||||
|
{
|
||||||
|
GlobalBodyRedactors = new List<string> { UncompilablePattern },
|
||||||
|
});
|
||||||
|
|
||||||
|
var result = RequestOf(redactor.Apply(NewEvent()));
|
||||||
|
|
||||||
|
Assert.DoesNotContain(Secret, result);
|
||||||
|
Assert.Equal(AuditRedactionPrimitives.OverRedactedEventMarker, result);
|
||||||
|
Assert.Equal(1, counter.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The dangerous mixed case: one good pattern and one unusable one. Applying
|
||||||
|
/// only the good pattern looks like success while the unusable pattern's
|
||||||
|
/// target sails through, so the whole payload must still be suppressed.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void OneUnavailablePatternAmongWorkingOnes_StillSuppressesPayload()
|
||||||
|
{
|
||||||
|
var (redactor, counter) = Build(new AuditLogOptions
|
||||||
|
{
|
||||||
|
GlobalBodyRedactors = new List<string> { WorkingPattern, UncompilablePattern },
|
||||||
|
});
|
||||||
|
|
||||||
|
var result = RequestOf(redactor.Apply(NewEvent()));
|
||||||
|
|
||||||
|
Assert.Equal(AuditRedactionPrimitives.OverRedactedEventMarker, result);
|
||||||
|
Assert.Equal(1, counter.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UnavailablePerTargetBodyRedactor_SuppressesPayload()
|
||||||
|
{
|
||||||
|
var opts = new AuditLogOptions();
|
||||||
|
opts.PerTargetOverrides["svc"] = new PerTargetRedactionOverride
|
||||||
|
{
|
||||||
|
AdditionalBodyRedactors = new List<string> { UncompilablePattern },
|
||||||
|
};
|
||||||
|
var (redactor, counter) = Build(opts);
|
||||||
|
|
||||||
|
var result = RequestOf(redactor.Apply(NewEvent(target: "svc")));
|
||||||
|
|
||||||
|
Assert.Equal(AuditRedactionPrimitives.OverRedactedEventMarker, result);
|
||||||
|
Assert.Equal(1, counter.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// SQL-parameter redactors reach the cache by a separate path whose bare
|
||||||
|
/// <c>false</c> return conflated "not configured" with "will not compile".
|
||||||
|
/// CLAUDE.md records SQL parameter capture as on by default, so this path
|
||||||
|
/// leaks parameter values.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void UnavailableSqlParamRedactor_SuppressesPayload()
|
||||||
|
{
|
||||||
|
var opts = new AuditLogOptions();
|
||||||
|
opts.PerTargetOverrides["conn"] = new PerTargetRedactionOverride
|
||||||
|
{
|
||||||
|
RedactSqlParamsMatching = UncompilablePattern,
|
||||||
|
};
|
||||||
|
var (redactor, counter) = Build(opts);
|
||||||
|
|
||||||
|
var result = RequestOf(
|
||||||
|
redactor.Apply(NewEvent(target: "conn.Table", channel: AuditChannel.DbOutbound)));
|
||||||
|
|
||||||
|
Assert.Equal(AuditRedactionPrimitives.OverRedactedEventMarker, result);
|
||||||
|
Assert.Equal(1, counter.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A SQL redactor configured for a DIFFERENT connection must not suppress
|
||||||
|
/// this row — otherwise fail-closed would over-reach into unrelated targets.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void UnavailableSqlParamRedactor_OnAnotherConnection_DoesNotSuppressThisRow()
|
||||||
|
{
|
||||||
|
var opts = new AuditLogOptions();
|
||||||
|
opts.PerTargetOverrides["other"] = new PerTargetRedactionOverride
|
||||||
|
{
|
||||||
|
RedactSqlParamsMatching = UncompilablePattern,
|
||||||
|
};
|
||||||
|
var (redactor, counter) = Build(opts);
|
||||||
|
|
||||||
|
var result = RequestOf(
|
||||||
|
redactor.Apply(NewEvent(target: "conn.Table", channel: AuditChannel.DbOutbound)));
|
||||||
|
|
||||||
|
Assert.Contains(Secret, result);
|
||||||
|
Assert.Equal(0, counter.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The reload path: a pattern that becomes unavailable AFTER construction must
|
||||||
|
/// also fail closed. This is the shape that made the original defect a live
|
||||||
|
/// risk rather than a boot-time one.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void PatternThatBecomesUnavailableOnReload_FailsClosed()
|
||||||
|
{
|
||||||
|
var monitor = new MutableMonitor(new AuditLogOptions
|
||||||
|
{
|
||||||
|
GlobalBodyRedactors = new List<string> { WorkingPattern },
|
||||||
|
});
|
||||||
|
var counter = new CountingCounter();
|
||||||
|
using var redactor = new ScadaBridgeAuditRedactor(
|
||||||
|
monitor, NullLogger<ScadaBridgeAuditRedactor>.Instance, counter);
|
||||||
|
|
||||||
|
Assert.DoesNotContain(Secret, RequestOf(redactor.Apply(NewEvent())));
|
||||||
|
|
||||||
|
monitor.Set(new AuditLogOptions
|
||||||
|
{
|
||||||
|
GlobalBodyRedactors = new List<string> { UncompilablePattern },
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
AuditRedactionPrimitives.OverRedactedEventMarker,
|
||||||
|
RequestOf(redactor.Apply(NewEvent())));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Warm-up must happen at construction, so the compile budget is spent off the
|
||||||
|
/// hot path (audit-log roadmap: "patterns precompiled at startup"). Asserted
|
||||||
|
/// behaviourally: the FIRST event after construction must already be handled
|
||||||
|
/// by a compiled pattern.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void ConstructionWarmsPatterns_SoTheFirstEventIsNotTheOneThatCompiles()
|
||||||
|
{
|
||||||
|
var monitor = new MutableMonitor(new AuditLogOptions
|
||||||
|
{
|
||||||
|
GlobalBodyRedactors = new List<string> { WorkingPattern },
|
||||||
|
});
|
||||||
|
|
||||||
|
using var redactor = new ScadaBridgeAuditRedactor(
|
||||||
|
monitor, NullLogger<ScadaBridgeAuditRedactor>.Instance, new CountingCounter());
|
||||||
|
|
||||||
|
// Construction alone must have read the options to warm them; a redactor
|
||||||
|
// that only compiled lazily would not have touched CurrentValue yet.
|
||||||
|
Assert.True(
|
||||||
|
monitor.CurrentValueReads > 0,
|
||||||
|
"the redactor must read its options at construction to precompile patterns");
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class CountingCounter : IAuditRedactionFailureCounter
|
||||||
|
{
|
||||||
|
private int _count;
|
||||||
|
public int Count => Volatile.Read(ref _count);
|
||||||
|
public void Increment() => Interlocked.Increment(ref _count);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class StaticMonitor : IOptionsMonitor<AuditLogOptions>
|
||||||
|
{
|
||||||
|
private readonly AuditLogOptions _value;
|
||||||
|
public StaticMonitor(AuditLogOptions value) => _value = value;
|
||||||
|
public AuditLogOptions CurrentValue => _value;
|
||||||
|
public AuditLogOptions Get(string? name) => _value;
|
||||||
|
public IDisposable? OnChange(Action<AuditLogOptions, string?> listener) => null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class MutableMonitor : IOptionsMonitor<AuditLogOptions>
|
||||||
|
{
|
||||||
|
private AuditLogOptions _value;
|
||||||
|
private readonly List<Action<AuditLogOptions, string?>> _listeners = new();
|
||||||
|
private int _reads;
|
||||||
|
|
||||||
|
public MutableMonitor(AuditLogOptions value) => _value = value;
|
||||||
|
|
||||||
|
public int CurrentValueReads => Volatile.Read(ref _reads);
|
||||||
|
|
||||||
|
public AuditLogOptions CurrentValue
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
Interlocked.Increment(ref _reads);
|
||||||
|
return _value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public AuditLogOptions Get(string? name) => CurrentValue;
|
||||||
|
|
||||||
|
public IDisposable? OnChange(Action<AuditLogOptions, string?> listener)
|
||||||
|
{
|
||||||
|
lock (_listeners)
|
||||||
|
{
|
||||||
|
_listeners.Add(listener);
|
||||||
|
}
|
||||||
|
return new Noop();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Set(AuditLogOptions value)
|
||||||
|
{
|
||||||
|
_value = value;
|
||||||
|
Action<AuditLogOptions, string?>[] snapshot;
|
||||||
|
lock (_listeners)
|
||||||
|
{
|
||||||
|
snapshot = _listeners.ToArray();
|
||||||
|
}
|
||||||
|
foreach (var l in snapshot)
|
||||||
|
{
|
||||||
|
l(value, Options.DefaultName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class Noop : IDisposable
|
||||||
|
{
|
||||||
|
public void Dispose() { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -64,8 +64,16 @@ public class GrpcCentralTransportTests : IAsyncLifetime
|
|||||||
backoffBase: TimeSpan.FromMilliseconds(50),
|
backoffBase: TimeSpan.FromMilliseconds(50),
|
||||||
backoffCap: TimeSpan.FromMilliseconds(200));
|
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
|
private HttpMessageHandler HandlerFor(string endpoint) => endpoint == EndpointA
|
||||||
? new ToggleHandler(_nodeA.Server.CreateHandler(), () => _nodeA.IsUp)
|
? new GrpcStatusHandler(
|
||||||
|
new ToggleHandler(_nodeA.Server.CreateHandler(), () => _nodeA.IsUp),
|
||||||
|
() => _nodeAForcedStatus)
|
||||||
: new ToggleHandler(_nodeB.Server.CreateHandler(), () => _nodeB.IsUp);
|
: new ToggleHandler(_nodeB.Server.CreateHandler(), () => _nodeB.IsUp);
|
||||||
|
|
||||||
private GrpcCentralTransport NewTransport(CentralChannelProvider provider, CommunicationOptions? options = null)
|
private GrpcCentralTransport NewTransport(CentralChannelProvider provider, CommunicationOptions? options = null)
|
||||||
@@ -182,10 +190,43 @@ public class GrpcCentralTransportTests : IAsyncLifetime
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task DeadlineExceeded_IsNotRetriedOnThePeer()
|
public async Task DeadlineExceeded_IsNotRetriedOnThePeer()
|
||||||
{
|
{
|
||||||
// THE hard rule. Node A is UP but never replies, so the call deadlines. The transport must
|
// THE hard rule: on DeadlineExceeded the call may ALREADY have executed, so the transport
|
||||||
// surface Status.Failure and must NOT try node B (the call may already have executed).
|
// 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();
|
_nodeA.SetBlackHole();
|
||||||
var shortDeadline = new CommunicationOptions { NotificationForwardTimeout = TimeSpan.FromMilliseconds(300) };
|
_nodeB.SetBlackHole();
|
||||||
|
var shortDeadline = new CommunicationOptions
|
||||||
|
{
|
||||||
|
NotificationForwardTimeout = TimeSpan.FromMilliseconds(300),
|
||||||
|
};
|
||||||
|
|
||||||
using var provider = NewProvider();
|
using var provider = NewProvider();
|
||||||
var transport = NewTransport(provider, shortDeadline);
|
var transport = NewTransport(provider, shortDeadline);
|
||||||
@@ -193,10 +234,9 @@ public class GrpcCentralTransportTests : IAsyncLifetime
|
|||||||
|
|
||||||
transport.SubmitNotification(NewSubmit("n1"), inbox.Ref);
|
transport.SubmitNotification(NewSubmit("n1"), inbox.Ref);
|
||||||
|
|
||||||
// A per-call deadline is applied (the call returns fast instead of hanging on the black hole).
|
// 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)));
|
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static NotificationSubmit NewSubmit(string id) => new(
|
private static NotificationSubmit NewSubmit(string id) => new(
|
||||||
@@ -250,6 +290,49 @@ public class GrpcCentralTransportTests : IAsyncLifetime
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <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>
|
/// <summary>A gRPC channel handler that throws (a refused connection) while its node is "down".</summary>
|
||||||
private sealed class ToggleHandler : DelegatingHandler
|
private sealed class ToggleHandler : DelegatingHandler
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user