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:
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user