fix(configuration): resolve Low code-review findings (Configuration-004,005,007,010,011)

- Configuration-004: NodePermissions stored as int to match the EF
  HasConversion<int>() in OtOpcUaConfigDbContext.ConfigureNodeAcl.
- Configuration-005: serialise LiteDbConfigCache.PutAsync so concurrent
  Put for the same (ClusterId, GenerationId) cannot duplicate rows.
- Configuration-007: rethrow OperationCanceledException from
  GenerationApplier.ApplyPass when the caller's token is cancelled.
- Configuration-010: scrub secrets and drop the full exception object
  from the ResilientConfigReader fallback warning log.
- Configuration-011: pin the previously-uncovered GenerationApplier
  cancellation and path-length / publish-validation paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-23 05:38:18 -04:00
parent 8be6afbda4
commit b92fea15d4
10 changed files with 327 additions and 27 deletions

View File

@@ -128,4 +128,94 @@ public sealed class GenerationApplierTests
result.Succeeded.ShouldBeFalse();
result.Errors.ShouldContain(e => e.Contains("tag-bad") && e.Contains("simulated"));
}
// ------------------------------------------------------------------------------------
// Configuration-011 — pin the documented ordering behaviour: a thrown Removed callback
// records an entity error but the applier still runs the Added/Modified passes (the
// current contract — see GenerationApplier comment about cascades settling).
// ------------------------------------------------------------------------------------
[Fact]
public async Task Apply_continues_to_Added_pass_when_a_Removed_callback_throws()
{
var callLog = new List<string>();
var applier = new GenerationApplier(new ApplyCallbacks
{
OnTag = (c, _) =>
{
callLog.Add($"tag:{c.Kind}:{c.LogicalId}");
if (c.Kind == ChangeKind.Removed)
throw new InvalidOperationException("removed-failed");
return Task.CompletedTask;
},
});
var from = SnapshotWith(tags: [Tag("tag-old", "X")]);
var to = SnapshotWith(tags: [Tag("tag-new", "Y")]);
var result = await applier.ApplyAsync(from, to, CancellationToken.None);
result.Succeeded.ShouldBeFalse();
result.Errors.ShouldContain(e => e.Contains("tag-old") && e.Contains("removed-failed"));
// The Added pass still runs even though Removed failed.
callLog.ShouldContain("tag:Removed:tag-old");
callLog.ShouldContain("tag:Added:tag-new");
}
// ------------------------------------------------------------------------------------
// Configuration-007 — ApplyPass must propagate OperationCanceledException rather than
// recording it as an entity error. Cancellation between passes must also halt the apply.
// ------------------------------------------------------------------------------------
[Fact]
public async Task Apply_propagates_OperationCanceledException_from_callback_when_token_cancelled()
{
// A callback that observes a cancelled token and throws OperationCanceledException
// must abort the entire apply, not be silently swallowed and recorded as an error.
using var cts = new CancellationTokenSource();
var applier = new GenerationApplier(new ApplyCallbacks
{
OnTag = (c, ct) =>
{
cts.Cancel();
ct.ThrowIfCancellationRequested();
return Task.CompletedTask;
},
});
var to = SnapshotWith(tags: [Tag("tag-1", "A")]);
await Should.ThrowAsync<OperationCanceledException>(async () =>
await applier.ApplyAsync(from: null, to, cts.Token));
}
[Fact]
public async Task Apply_stops_between_passes_when_cancellation_requested()
{
// After a Removed pass completes, the applier should observe cancellation before
// running the Added/Modified passes — not silently keep walking.
var callLog = new List<string>();
using var cts = new CancellationTokenSource();
var applier = new GenerationApplier(new ApplyCallbacks
{
OnTag = (c, _) =>
{
callLog.Add($"tag:{c.Kind}:{c.LogicalId}");
// Cancel after the Removed pass finishes — before the Added pass runs.
if (c.Kind == ChangeKind.Removed) cts.Cancel();
return Task.CompletedTask;
},
});
// `from` has tag-1, `to` has tag-2 — produces one Removed + one Added.
var from = SnapshotWith(tags: [Tag("tag-1", "A")]);
var to = SnapshotWith(tags: [Tag("tag-2", "B")]);
await Should.ThrowAsync<OperationCanceledException>(async () =>
await applier.ApplyAsync(from, to, cts.Token));
callLog.ShouldContain("tag:Removed:tag-1");
callLog.ShouldNotContain("tag:Added:tag-2",
"Added pass must not run after cancellation observed between passes");
}
}