fix(r2-02): pipeline Build clamps policy values — building a pipeline can never throw Polly ValidationException (01/S-6 belt-and-braces + brick repro)

This commit is contained in:
Joseph Doherty
2026-07-13 09:55:05 -04:00
parent 294d74c1de
commit 8d306a1bd2
4 changed files with 97 additions and 5 deletions
@@ -325,6 +325,37 @@ public sealed class DriverResiliencePipelineBuilderTests
e.Message.Contains("host-9"));
}
/// <summary>
/// 01/S-6 builder invariant: building a pipeline from a hostile, directly-constructed
/// <see cref="CapabilityPolicy"/> (bypassing the parser) must NEVER throw Polly's
/// <c>ValidationException</c>. Sweeps the full matrix of {int.MinValue, -1, 0, 1, int.MaxValue}
/// across timeout/retry/breaker and asserts every built pipeline executes a trivial call.
/// </summary>
[Fact]
public async Task Build_NeverThrows_ForHostileDirectOptions()
{
int[] hostile = { int.MinValue, -1, 0, 1, int.MaxValue };
var host = 0;
foreach (var t in hostile)
foreach (var r in hostile)
foreach (var b in hostile)
{
var options = new DriverResilienceOptions
{
Tier = DriverTier.A,
CapabilityPolicies = new Dictionary<DriverCapability, CapabilityPolicy>
{
[DriverCapability.Read] = new(TimeoutSeconds: t, RetryCount: r, BreakerFailureThreshold: b),
},
};
var builder = new DriverResiliencePipelineBuilder();
var pipeline = builder.GetOrCreate("hostile", $"h{host++}", DriverCapability.Read, options);
var result = await pipeline.ExecuteAsync(_ => ValueTask.FromResult(1), CancellationToken.None);
result.ShouldBe(1, $"pipeline built from hostile (t={t}, r={r}, b={b}) must execute, not throw");
}
}
/// <summary>Minimal in-memory <see cref="ILogger"/> capturing (level, formatted-message) pairs so the
/// resilience logging contract can be asserted without a real logging provider.</summary>
private sealed class CapturingLogger : ILogger
@@ -0,0 +1,48 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
namespace ZB.MOM.WW.OtOpcUa.Core.Tests.Resilience;
/// <summary>
/// Load-bearing regression guard for 01/S-6 — an operator-authorable out-of-range
/// ResilienceConfig value must NEVER brick a healthy driver. Before the R2-02 hardening pass,
/// <c>"timeoutSeconds": 0</c> (→ <c>TimeSpan.Zero</c>, outside Polly's [10 ms, 24 h] window) and
/// <c>"breakerFailureThreshold": 1</c> (below Polly's MinimumThroughput floor of 2) each made
/// <see cref="DriverResiliencePipelineBuilder"/> throw <c>ValidationException</c> inside the
/// memoizing <c>GetOrAdd</c> factory on EVERY capability call for that (instance, host,
/// capability) — a permanent brick while the driver is healthy. The parser now clamps and the
/// builder belt-and-braces clamps, so a pipeline built from a parsed hostile config executes.
/// </summary>
[Trait("Category", "Unit")]
public sealed class ResilienceConfigBrickTests
{
/// <summary>A parsed <c>timeoutSeconds:0</c> override must not brick a capability call.</summary>
[Fact]
public async Task ParsedConfig_WithZeroTimeout_DoesNotBrickCapabilityCalls()
{
var options = DriverResilienceOptionsParser.ParseOrDefaults(
DriverTier.A, "{\"capabilityPolicies\":{\"Read\":{\"timeoutSeconds\":0}}}", out _);
var pipeline = new DriverResiliencePipelineBuilder()
.GetOrCreate("i1", "h1", DriverCapability.Read, options);
var result = await pipeline.ExecuteAsync(_ => ValueTask.FromResult(42), CancellationToken.None);
result.ShouldBe(42);
}
/// <summary>A parsed <c>breakerFailureThreshold:1</c> override must not brick a capability call.</summary>
[Fact]
public async Task ParsedConfig_WithBreakerThresholdOne_DoesNotBrickCapabilityCalls()
{
var options = DriverResilienceOptionsParser.ParseOrDefaults(
DriverTier.A, "{\"capabilityPolicies\":{\"Read\":{\"breakerFailureThreshold\":1}}}", out _);
var pipeline = new DriverResiliencePipelineBuilder()
.GetOrCreate("i1", "h1", DriverCapability.Read, options);
var result = await pipeline.ExecuteAsync(_ => ValueTask.FromResult(42), CancellationToken.None);
result.ShouldBe(42);
}
}