Merge R2-02 ResilienceConfig hardening (arch-review round 2) [PR #431]
v2-ci / build (push) Successful in 3m53s
v2-ci / unit-tests (push) Failing after 8m15s

Findings 01/S-6 (High — clamp hostile ResilienceConfig, no ValidationException
driver-brick), 01/U-6 (delete dead bulkhead knob), 01/S-8=03/S12 (Write/Ack
non-idempotent + retry->0), 04/C-7 (JsonObject round-trip preserves unknown
keys), 01/S-7=03/S13 (per-Create options-generation kills respawn cache race).
T15/T18 deferred. STATUS.md conflict resolved additively. Build clean.
This commit is contained in:
Joseph Doherty
2026-07-13 10:29:43 -04:00
36 changed files with 1016 additions and 197 deletions
@@ -1,3 +1,4 @@
using System.Text.Json.Nodes;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers;
@@ -6,6 +7,59 @@ using ZB.MOM.WW.OtOpcUa.Core.Resilience;
public class ResilienceFormModelTests
{
// ---- 04/C-7: non-lossy round-trip ----
[Fact]
public void Unknown_top_level_key_survives_round_trip()
{
// bulkheadMaxConcurrent doubles as the 01/U-6 continuity proof: the deleted knob's key survives.
var json = """{"bulkheadMaxConcurrent":16,"capabilityPolicies":{"Read":{"timeoutSeconds":5}}}""";
var back = ResilienceFormModel.FromJson(json).ToJson();
back.ShouldNotBeNull();
var obj = JsonNode.Parse(back)!.AsObject();
obj["bulkheadMaxConcurrent"]!.GetValue<int>().ShouldBe(16);
obj["capabilityPolicies"]!["Read"]!["timeoutSeconds"]!.GetValue<int>().ShouldBe(5);
}
[Fact]
public void Unknown_capability_entry_survives_round_trip()
{
var json = """{"capabilityPolicies":{"Read":{"timeoutSeconds":5},"FutureCap":{"timeoutSeconds":9}}}""";
var back = ResilienceFormModel.FromJson(json).ToJson();
back.ShouldNotBeNull();
var obj = JsonNode.Parse(back)!.AsObject();
obj["capabilityPolicies"]!["FutureCap"]!["timeoutSeconds"]!.GetValue<int>().ShouldBe(9);
obj["capabilityPolicies"]!["Read"]!["timeoutSeconds"]!.GetValue<int>().ShouldBe(5);
}
[Fact]
public void Unknown_per_policy_field_survives_round_trip()
{
var json = """{"capabilityPolicies":{"Read":{"timeoutSeconds":5,"futureField":7}}}""";
var back = ResilienceFormModel.FromJson(json).ToJson();
back.ShouldNotBeNull();
var read = JsonNode.Parse(back)!.AsObject()["capabilityPolicies"]!["Read"]!.AsObject();
read["timeoutSeconds"]!.GetValue<int>().ShouldBe(5);
read["futureField"]!.GetValue<int>().ShouldBe(7);
}
[Fact]
public void Malformed_json_sets_ParseFailed_and_ToJson_returns_original_text()
{
const string json = "{ not json";
var m = ResilienceFormModel.FromJson(json);
m.ParseFailed.ShouldBeTrue();
m.ToJson().ShouldBe(json); // never overwrite what it couldn't read
}
[Fact]
public void Blank_form_serializes_to_null()
=> new ResilienceFormModel().ToJson().ShouldBeNull();
@@ -13,7 +67,7 @@ public class ResilienceFormModelTests
[Fact]
public void Partial_override_round_trips()
{
var m = new ResilienceFormModel { BulkheadMaxConcurrent = 16 };
var m = new ResilienceFormModel { RecycleIntervalSeconds = 3600 };
m.Policies["Read"].TimeoutSeconds = 5;
m.Policies["Read"].RetryCount = 5;
@@ -21,7 +75,7 @@ public class ResilienceFormModelTests
json.ShouldNotBeNull();
var back = ResilienceFormModel.FromJson(json);
back.BulkheadMaxConcurrent.ShouldBe(16);
back.RecycleIntervalSeconds.ShouldBe(3600);
back.Policies["Read"].TimeoutSeconds.ShouldBe(5);
back.Policies["Write"].IsEmpty.ShouldBeTrue();
}
@@ -30,19 +84,48 @@ public class ResilienceFormModelTests
public void Malformed_json_yields_empty_model()
{
var m = ResilienceFormModel.FromJson("{ not valid json");
m.BulkheadMaxConcurrent.ShouldBeNull();
m.RecycleIntervalSeconds.ShouldBeNull();
m.Policies["Read"].IsEmpty.ShouldBeTrue();
}
[Fact]
public void Validate_flags_out_of_range_values_with_capability_and_range()
{
var m = new ResilienceFormModel();
m.Policies["Read"].TimeoutSeconds = 0; // < MinTimeoutSeconds
m.Policies["Read"].RetryCount = -1; // < MinRetryCount
m.Policies["Read"].BreakerFailureThreshold = 1; // below Polly MinimumThroughput floor
var msgs = m.Validate();
msgs.Count.ShouldBe(3);
msgs.ShouldContain(s => s.Contains("Read") && s.Contains("timeout"));
msgs.ShouldContain(s => s.Contains("Read") && s.Contains("retries"));
msgs.ShouldContain(s => s.Contains("Read") && s.Contains("breaker"));
}
[Fact]
public void Validate_passes_in_range_and_blank()
{
var blank = new ResilienceFormModel();
blank.Validate().ShouldBeEmpty();
var inRange = new ResilienceFormModel();
inRange.Policies["Read"].TimeoutSeconds = 5;
inRange.Policies["Read"].RetryCount = 3;
inRange.Policies["Read"].BreakerFailureThreshold = 4;
inRange.Policies["Write"].BreakerFailureThreshold = 0; // 0 = breaker off, legal
inRange.Validate().ShouldBeEmpty();
}
[Fact]
public void Emitted_json_is_consumable_by_the_runtime_parser()
{
var m = new ResilienceFormModel { BulkheadMaxConcurrent = 16 };
var m = new ResilienceFormModel();
m.Policies["Read"].TimeoutSeconds = 7;
var opts = DriverResilienceOptionsParser.ParseOrDefaults(DriverTier.B, m.ToJson(), out var diag);
diag.ShouldBeNull();
opts.BulkheadMaxConcurrent.ShouldBe(16);
opts.Resolve(DriverCapability.Read).TimeoutSeconds.ShouldBe(7);
opts.Resolve(DriverCapability.Write).RetryCount.ShouldBe(0);
}
@@ -75,6 +75,9 @@ public sealed class DriverInstanceActorResilienceWiringTests : RuntimeActorTestB
reply!.Success.ShouldBeTrue();
driver.Writes.Count.ShouldBeGreaterThanOrEqualTo(1); // the invoker's call-site actually ran
invoker.Calls.ShouldContain(c => c.Capability == DriverCapability.Write && c.Host == "plc-7");
// 03/S12: the dispatch site must default writes to NON-idempotent so the invoker's no-retry arm is
// authoritative — a command-shaped write (pulse, counter, supervisory) must never replay.
invoker.WriteCalls.ShouldContain(c => c.Host == "plc-7" && c.IsIdempotent == false);
}
/// <summary>Records every capability + resolved-host key routed through it, then delegates to the
@@ -83,6 +86,7 @@ public sealed class DriverInstanceActorResilienceWiringTests : RuntimeActorTestB
private sealed class RecordingInvoker : IDriverCapabilityInvoker
{
public ConcurrentQueue<(DriverCapability Capability, string Host)> Calls { get; } = new();
public ConcurrentQueue<(string Host, bool IsIdempotent)> WriteCalls { get; } = new();
public ValueTask<TResult> ExecuteAsync<TResult>(
DriverCapability capability, string hostName,
@@ -105,6 +109,7 @@ public sealed class DriverInstanceActorResilienceWiringTests : RuntimeActorTestB
Func<CancellationToken, ValueTask<TResult>> callSite, CancellationToken cancellationToken)
{
Calls.Enqueue((DriverCapability.Write, hostName));
WriteCalls.Enqueue((hostName, isIdempotent));
return callSite(cancellationToken);
}
}